From c1c48c9533c08d7d01ea695624944f0243201f13 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Sun, 19 Apr 2026 00:32:57 +0300 Subject: [PATCH 01/17] =?UTF-8?q?=D0=92=D1=8B=D1=80=D0=B0=D0=B2=D0=BD?= =?UTF-8?q?=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=20=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=BB=D1=8E=20=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BA=D1=82=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 84 ++- STYLEGUIDE.md | 231 ++++++++- avito/__init__.py | 15 +- avito/_env.py | 63 +++ avito/accounts/client.py | 41 +- avito/accounts/domain.py | 4 +- avito/accounts/mappers.py | 26 +- avito/accounts/models.py | 32 +- avito/ads/__init__.py | 8 + avito/ads/client.py | 204 +++++--- avito/ads/domain.py | 119 ++++- avito/ads/mappers.py | 60 +-- avito/ads/models.py | 88 ++-- avito/auth/mappers.py | 2 +- avito/auth/models.py | 2 +- avito/auth/settings.py | 144 +++++- avito/autoteka/client.py | 50 +- avito/autoteka/domain.py | 94 ++-- avito/autoteka/mappers.py | 34 +- avito/autoteka/models.py | 55 +- avito/client/client.py | 10 +- avito/config.py | 82 ++- avito/core/__init__.py | 12 + avito/core/exceptions.py | 97 +++- avito/core/mapping.py | 36 ++ avito/core/pagination.py | 27 +- avito/core/serialization.py | 66 +++ avito/core/transport.py | 103 +++- avito/core/types.py | 1 + avito/cpa/__init__.py | 18 + avito/cpa/client.py | 98 ++-- avito/cpa/domain.py | 83 +-- avito/cpa/mappers.py | 23 +- avito/cpa/models.py | 175 +++++-- avito/jobs/client.py | 169 +++--- avito/jobs/domain.py | 76 +-- avito/jobs/mappers.py | 42 +- avito/jobs/models.py | 61 ++- avito/messages/__init__.py | 0 avito/messages/message.py | 1 - avito/messenger/domain.py | 16 +- avito/messenger/mappers.py | 34 +- avito/messenger/models.py | 38 +- avito/orders/client.py | 90 ++-- avito/orders/domain.py | 174 +++---- avito/orders/mappers.py | 28 +- avito/orders/models.py | 50 +- avito/promotion/__init__.py | 46 +- avito/promotion/client.py | 236 ++++++--- avito/promotion/domain.py | 237 +++++++-- avito/promotion/mappers.py | 324 +++++++++--- avito/promotion/models.py | 220 +++++--- avito/ratings/client.py | 15 +- avito/ratings/domain.py | 25 +- avito/ratings/mappers.py | 8 +- avito/ratings/models.py | 39 +- avito/realty/client.py | 16 +- avito/realty/domain.py | 42 +- avito/realty/mappers.py | 34 +- avito/realty/models.py | 77 ++- avito/tariffs/client.py | 6 +- avito/tariffs/mappers.py | 4 +- avito/tariffs/models.py | 9 +- docs/index.md | 100 ---- docs/release.md | 55 -- tests/conftest.py | 9 + tests/fake_transport.py | 153 ++++++ tests/test_calltracking_contract_alignment.py | 100 ++++ tests/test_config.py | 206 ++++++++ tests/test_core.py | 113 ++++ tests/test_facade.py | 10 +- tests/test_no_raw_payload_contract.py | 41 ++ tests/test_no_valueerror_in_public_surface.py | 20 + tests/test_promotion_contract_alignment.py | 270 ++++++++++ tests/test_public_models.py | 229 +++++++++ tests/test_read_contract.py | 126 +++++ tests/test_realty_contract_alignment.py | 121 +++++ tests/test_stage10_autoteka.py | 55 +- tests/test_stage11_mock_transport_suite.py | 483 ++++++++++++++++++ tests/test_stage11_realty_ratings_tariffs.py | 40 +- tests/test_stage12_release_gate.py | 1 + tests/test_stage4_accounts_ads.py | 22 +- tests/test_stage4_promotion_write_contract.py | 322 ++++++++++++ tests/test_stage5_promotion_read_contract.py | 163 ++++++ tests/test_stage6_error_model.py | 120 +++++ tests/test_stage6_promotion.py | 82 +-- tests/test_stage7_orders.py | 54 +- tests/test_stage8_jobs.py | 37 +- tests/test_stage8_serialization_contract.py | 146 ++++++ tests/test_stage9_cpa.py | 61 ++- tests/test_stage9_transport_isolation.py | 229 +++++++++ uv.lock | 3 + 92 files changed, 6175 insertions(+), 1500 deletions(-) create mode 100644 avito/_env.py create mode 100644 avito/core/mapping.py create mode 100644 avito/core/serialization.py delete mode 100644 avito/messages/__init__.py delete mode 100644 avito/messages/message.py delete mode 100644 docs/index.md delete mode 100644 docs/release.md create mode 100644 tests/fake_transport.py create mode 100644 tests/test_calltracking_contract_alignment.py create mode 100644 tests/test_config.py create mode 100644 tests/test_no_raw_payload_contract.py create mode 100644 tests/test_no_valueerror_in_public_surface.py create mode 100644 tests/test_promotion_contract_alignment.py create mode 100644 tests/test_public_models.py create mode 100644 tests/test_read_contract.py create mode 100644 tests/test_realty_contract_alignment.py create mode 100644 tests/test_stage11_mock_transport_suite.py create mode 100644 tests/test_stage4_promotion_write_contract.py create mode 100644 tests/test_stage5_promotion_read_contract.py create mode 100644 tests/test_stage6_error_model.py create mode 100644 tests/test_stage8_serialization_contract.py create mode 100644 tests/test_stage9_transport_isolation.py create mode 100644 uv.lock diff --git a/README.md b/README.md index f140311..f96f307 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ pip install avito-py ## Быстрый старт +Получение ключей - https://www.avito.ru/professionals/api + ```python from avito import AvitoClient @@ -40,12 +42,51 @@ print(ad.title) По умолчанию настройки читаются из переменных окружения с префиксом `AVITO_`. -Базовые переменные: +Официальный способ конфигурации SDK: + +```python +from avito import AuthSettings, AvitoClient, AvitoSettings + +settings = AvitoSettings( + base_url="https://api.avito.ru", + user_id=123, + auth=AuthSettings( + client_id="client-id", + client_secret="client-secret", + ), +) +client = AvitoClient(settings) +``` + +Инициализация из окружения и `.env`: + +```python +from avito import AvitoClient, AvitoSettings + +settings = AvitoSettings.from_env() +client = AvitoClient.from_env() +``` + +Поддерживаемые env-переменные и alias-имена: + +- `AVITO_BASE_URL`, alias: `BASE_URL` +- `AVITO_USER_ID`, alias: `USER_ID` +- `AVITO_AUTH__CLIENT_ID`, alias: `AVITO_CLIENT_ID`, `CLIENT_ID` +- `AVITO_AUTH__CLIENT_SECRET`, alias: `AVITO_CLIENT_SECRET`, `AVITO_SECRET`, `CLIENT_SECRET`, `SECRET` +- `AVITO_AUTH__REFRESH_TOKEN`, alias: `AVITO_REFRESH_TOKEN`, `REFRESH_TOKEN` +- `AVITO_AUTH__SCOPE`, alias: `AVITO_SCOPE`, `SCOPE` +- `AVITO_AUTH__TOKEN_URL`, alias: `AVITO_TOKEN_URL`, `TOKEN_URL` +- `AVITO_AUTH__LEGACY_TOKEN_URL`, alias: `AVITO_LEGACY_TOKEN_URL`, `LEGACY_TOKEN_URL` +- `AVITO_AUTH__AUTOTEKA_TOKEN_URL`, alias: `AVITO_AUTOTEKA_TOKEN_URL`, `AUTOTEKA_TOKEN_URL` +- `AVITO_AUTH__AUTOTEKA_CLIENT_ID`, alias: `AVITO_AUTOTEKA_CLIENT_ID`, `AUTOTEKA_CLIENT_ID` +- `AVITO_AUTH__AUTOTEKA_CLIENT_SECRET`, alias: `AVITO_AUTOTEKA_CLIENT_SECRET`, `AUTOTEKA_CLIENT_SECRET` +- `AVITO_AUTH__AUTOTEKA_SCOPE`, alias: `AVITO_AUTOTEKA_SCOPE`, `AUTOTEKA_SCOPE` + +Правила resolution: -- `AVITO_AUTH__CLIENT_ID` -- `AVITO_AUTH__CLIENT_SECRET` -- `AVITO_AUTH__REFRESH_TOKEN` -- `AVITO_BASE_URL` +- значения из process environment имеют приоритет над `.env`; +- `AvitoSettings.from_env()` и `AvitoClient.from_env()` детерминированно читают `.env` из текущей рабочей директории или из переданного `env_file`; +- при отсутствии `client_id` или `client_secret` SDK завершает инициализацию с typed-ошибкой `ConfigurationError`. ## Примеры по доменам @@ -125,6 +166,30 @@ with AvitoClient() as avito: records = avito.call_tracking_call(call_id=10).download() ``` +## Пагинация + +Публичные list-операции, которые поддерживают lazy pagination, возвращают обычные SDK-результаты, а поле `items` в них ведет себя как list-like коллекция `PaginatedList`. + +Текущий стабильный контракт: + +- первая страница загружается сразу, остальные страницы подгружаются только при чтении элементов за ее пределами; +- доступ к уже загруженным элементам не делает повторных запросов; +- частичная итерация и slicing загружают только необходимые страницы; +- явная полная материализация выполняется через `items.materialize()`. + +Пример: + +```python +from avito import AvitoClient + +with AvitoClient() as avito: + result = avito.ad(user_id=123).list(status="active", limit=50) + + first = result.items[0] + preview = result.items[:10] + all_items = result.items.materialize() +``` + ### Автотека ```python @@ -157,11 +222,12 @@ client = AvitoClient() info = client.debug_info() print(info.base_url) +print(info.user_id) print(info.retry_max_attempts) client.close() ``` -`debug_info()` подходит для smoke-проверок окружения и диагностики конфигурации. Access token, `client_secret` и `Authorization` header в этот снимок не попадают. +`debug_info()` подходит для smoke-проверок окружения и диагностики конфигурации. Стабильный контракт включает `base_url`, `user_id`, флаг `requires_auth`, таймауты и retry-настройки. Access token, `client_secret` и `Authorization` header в этот снимок не попадают. ## Проверки качества @@ -201,7 +267,5 @@ git push origin v1.0.2 ## Документация репозитория -- [STYLEGUIDE.md](STYLEGUIDE.md) — нормативные архитектурные правила. -- [TODO.md](TODO.md) — этапы реализации и релизный gate. -- [docs/inventory.md](docs/inventory.md) — матрица соответствия swagger-операций и публичного API SDK. -- [docs/release.md](docs/release.md) — политика changelog и checklist релиза. +- [STYLEGUIDE.md](STYLEGUIDE.md) — нормативные архитектурные правила +- [docs/inventory.md](docs/inventory.md) — матрица соответствия swagger-операций и публичного API SDK diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index 76037ec..6452476 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -21,6 +21,7 @@ - Внешний код не должен работать с сырыми `dict[str, Any]`, если можно вернуть типизированный объект. - Исключения должны быть явными и доменными, без `assert False` для управления потоком. - Любое сетевое взаимодействие считается потенциально нестабильным. +- Публичные контракты SDK фиксируются явно и меняются только осознанно. ## Целевая архитектура пакетов @@ -35,6 +36,7 @@ avito/ __init__.py models.py provider.py + settings.py core/ __init__.py transport.py @@ -42,12 +44,23 @@ avito/ exceptions.py types.py pagination.py + accounts/ + __init__.py + client.py + models.py + mappers.py ads/ __init__.py client.py models.py enums.py mappers.py + promotion/ + __init__.py + client.py + models.py + enums.py + mappers.py messenger/ __init__.py client.py @@ -67,7 +80,7 @@ avito/ - `core/` содержит только общую инфраструктуру, без логики конкретного API-раздела. - Каждый раздел API живет в отдельном пакете: `ads`, `messenger`, `orders`, `autoload` и т.д. - В каждом разделе допускаются только модули, относящиеся к этому разделу. -- `avito/client.py` или `avito/__init__.py` содержит только высокоуровневую точку входа. +- `avito/client.py` и `avito/__init__.py` содержат только высокоуровневую точку входа и публичные экспорты. ## Публичный API библиотеки @@ -75,9 +88,9 @@ avito/ ```python client = AvitoClient(settings) -ad = client.ads.get(ad_id) -messages = client.messenger.list_chats() -stats = client.analytics.get_item_stats(...) +profile = client.account().get_self() +listing = client.ad(item_id=42, user_id=123).get() +stats = client.ad_stats(user_id=123).get_item_stats(item_ids=[42]) ``` Правила: @@ -87,6 +100,27 @@ stats = client.analytics.get_item_stats(...) - Публичные методы возвращают доменные модели, коллекции доменных моделей или типизированные result-объекты. - Сырые ответы API допустимы только во внутренних слоях или в явно обозначенных low-level методах. +### Что считается публичным контрактом SDK + +Нормативно в публичный контракт входят: + +- пакет `avito` и его экспорты `AvitoClient`, `AvitoSettings`, `AuthSettings`; +- фабрики ресурсов у `AvitoClient`, например `account()`, `ad()`, `ad_stats()`, `promotion_order()`; +- публичные модели из `avito..models`; +- typed exceptions из `avito.core.exceptions`; +- lazy pagination контракт `PaginatedList`; +- стабильная сериализация публичных моделей через `to_dict()` и `model_dump()`; +- безопасный diagnostic contract метода `debug_info()`. + +Нормативно не входят в публичный контракт: + +- transport request/response shapes; +- внутренние mapper-объекты; +- `raw_payload`, служебные dataclass-ы transport-слоя и внутренние DTO; +- shape исходного JSON-ответа Avito API. + +Внутренние изменения допустимы, пока публичные сигнатуры, возвращаемые модели, сериализация и типы ошибок остаются стабильными. + ## Классы и ответственность Обязательное разделение: @@ -116,6 +150,11 @@ stats = client.analytics.get_item_stats(...) - Для необязательных полей использовать `T | None`, а не неявные значения. - Вложенные структуры тоже должны иметь собственные типизированные dataclass-модели. - Не использовать `dict` как substitute для модели предметной области. +- Все публичные read/write методы возвращают только нормализованные модели SDK, а не transport-layer объекты. +- Для стабильных публичных моделей должны быть явно определены обязательные и nullable-поля. +- Каждая публичная модель должна предоставлять единообразную сериализацию через `to_dict()` и `model_dump()`. +- Сериализация публичных моделей должна быть JSON-compatible и рекурсивной для вложенных SDK-моделей. +- В публичных моделях запрещены transport/internal implementation fields. Пример: @@ -184,6 +223,7 @@ no_implicit_optional = true - Таймауты задаются явно. - Заголовки авторизации подставляются transport/auth слоем, а не бизнес-методами. - Формирование URL, обработка ошибок, retry и логирование концентрируются в transport. +- Transport-детали не должны быть частью публичных сигнатур, docstrings и serialization. Рекомендация: @@ -220,8 +260,8 @@ no_implicit_optional = true - `ApiTimeouts` - `TransportError` - `RateLimitError` -- `AuthenticationError` -- `ServerError` +- `AuthorizationError` +- `UpstreamApiError` ## Ошибки и исключения @@ -230,24 +270,36 @@ no_implicit_optional = true Правила: - Для ошибок SDK создается иерархия собственных исключений в `core/exceptions.py`. -- Ошибка должна содержать минимум: HTTP status, код ошибки Avito при наличии, человекочитаемое сообщение, исходный response payload при безопасной необходимости. +- Ошибка должна содержать минимум: `operation`, HTTP status, код ошибки Avito при наличии, человекочитаемое сообщение и безопасные metadata. - Ошибки 4xx и 5xx должны различаться типами. - Ошибки парсинга и ошибки transport должны различаться. +- Mapping transport/HTTP/API ошибок в публичные ошибки SDK должен быть централизован. +- Секреты, токены и чувствительные headers должны автоматически санитизироваться в сообщении и metadata. +- Неизвестная ошибка upstream не должна протекать наружу как сырой transport exception. Пример иерархии: ```python class AvitoError(Exception): ... class TransportError(AvitoError): ... -class AuthenticationError(AvitoError): ... -class PermissionDeniedError(AvitoError): ... -class NotFoundError(AvitoError): ... class ValidationError(AvitoError): ... +class AuthorizationError(AvitoError): ... class RateLimitError(AvitoError): ... -class ServerError(AvitoError): ... +class ConflictError(AvitoError): ... +class UnsupportedOperationError(AvitoError): ... +class UpstreamApiError(AvitoError): ... class ResponseMappingError(AvitoError): ... ``` +Нормативный mapping: + +- `400` и `422` маппятся в `ValidationError`, если это соответствует контракту операции; +- `401` и `403` маппятся в `AuthorizationError`; +- `409` маппится в `ConflictError`; +- `429` маппится в `RateLimitError`; +- неподдерживаемая операция приводит к `UnsupportedOperationError`; +- остальные неизвестные ошибки upstream маппятся в `UpstreamApiError`. + ## Mapping и преобразование данных JSON от Avito — это внешний контракт, а не внутренняя модель приложения. @@ -258,12 +310,92 @@ JSON от Avito — это внешний контракт, а не внутре - Логика "обогащения" данных выполняется после transport, но до возврата объекта пользователю. - Обогащение должно быть детерминированным и не ломать исходный контракт метода. - Если обогащение дорогое или требует дополнительных запросов, оно должно быть явно обозначено в API. +- Централизовать преобразование transport response в публичные модели SDK. +- Один и тот же ресурс должен маппиться в один и тот же публичный тип независимо от вариаций upstream payload внутри допустимого диапазона. +- Публичные docstring и сигнатуры не должны требовать знания upstream JSON shape. Рекомендация: - Использовать `mappers.py` внутри раздела API. - Не смешивать mapping с HTTP-вызовом в одном методе. +## Публичные read-контракты + +Read-операции должны быть выровнены по форме результата, nullable-поведению и неймингу полей. + +Правила: + +- `account().get_self()` возвращает `AccountProfile`; +- `ad().get(...)` возвращает `Listing`; +- `ad().list(...)` возвращает коллекцию или пагинируемый результат из `Listing`; +- `ad_stats().get_item_stats(...)` возвращает коллекцию `ListingStats`; +- `ad_stats().get_calls_stats(...)` возвращает коллекцию `CallStats`; +- `ad_stats().get_account_spendings(...)` возвращает `AccountSpendings` или иную зафиксированную контрактом SDK модель; +- пустой или частично заполненный upstream payload не должен ломать read-контракт, если модель допускает `None` для отсутствующих значений; +- consumer-код не должен знать структуру raw Avito response для использования read-методов. + +Для стабильных публичных read/write результатов нормативно закрепляются следующие canonical типы: + +- `AccountProfile` +- `Listing` +- `ListingStats` +- `CallStats` +- `AccountSpendings` +- `PromotionService` +- `PromotionOrder` +- `PromotionForecast` +- `PromotionActionResult` + +## Promotion write-контракт + +Официально поддерживаемые write-операции продвижения должны иметь единый публичный контракт. + +Правила: + +- write-операции продвижения принимают `dry_run: bool = False`; +- при `dry_run=True` метод обязан валидировать входные данные, собрать официальный request payload, не выполнять write-запрос и вернуть `PromotionActionResult` со статусом `preview` или `validated`; +- при `dry_run=False` метод обязан использовать тот же payload builder, выполнить write-запрос и вернуть тот же тип `PromotionActionResult`; +- невалидные входные параметры должны приводить к `ValidationError` до вызова transport; +- `request_payload` в результате должен соответствовать фактическому payload write-вызова; +- одинаковые входы в `dry_run=True` и `dry_run=False` должны формировать один и тот же payload. + +Стабильный контракт `PromotionActionResult`: + +- `action` +- `target` +- `status` +- `applied` +- `request_payload` +- `warnings` +- `upstream_reference` +- `details` + +Минимум следующие операции должны следовать этому контракту: + +- `bbip_promotion().create_order(...)` +- `ad_promotion().apply_vas(...)` +- `ad_promotion().apply_vas_package(...)` +- `ad_promotion().apply_vas_v2(...)` +- `trx_promotion().apply(...)` +- `trx_promotion().delete(...)` +- `target_action_pricing().update_manual(...)` +- `target_action_pricing().update_auto(...)` +- `target_action_pricing().delete(...)` + +## Promotion read-контракт + +Read-операции promotion surface должны возвращать только стабильные публичные SDK-модели. + +Правила: + +- `promotion_order().list_services(...)` возвращает коллекцию `PromotionService`; +- `promotion_order().list_orders(...)` возвращает коллекцию `PromotionOrder`; +- `promotion_order().get_order_status(...)` возвращает результат по зафиксированному контракту SDK; +- `bbip_promotion().get_suggests(...)` и `bbip_promotion().get_forecasts(...)` возвращают стабильные SDK-модели, а не transport shape; +- `target_action_pricing().get_bids(...)` и `target_action_pricing().get_promotions_by_item_ids(...)` возвращают стабильные SDK-модели; +- пустой список upstream корректно возвращается как пустая коллекция SDK-моделей; +- частичный upstream payload корректно маппится в nullable-поля публичных моделей. + ## Нейминг Правила: @@ -272,6 +404,7 @@ JSON от Avito — это внешний контракт, а не внутре - Имена классов: `PascalCase`. - Имена функций и методов: `snake_case`. - Имена публичных методов должны описывать бизнес-действие: `get_item`, `list_messages`, `create_discount_campaign`. +- Для публичных моделей использовать canonical имена предметной области, а не внутренние transport aliases. - Избегать абстрактных имен вроде `utils`, `helpers`, `common2`, `manager2`. ## Конфигурация @@ -279,35 +412,87 @@ JSON от Avito — это внешний контракт, а не внутре Правила: - Конфигурация SDK выделяется в отдельный модуль: `config.py` или `settings.py`. -- Переменные окружения читаются в одном месте. -- Публичные классы не должны напрямую зависеть от чтения environment. +- `AvitoSettings` и `AuthSettings` являются единственным официальным способом конфигурации SDK. - Пользователь SDK должен иметь возможность передать конфигурацию явно через объект настроек. +- Переменные окружения читаются в одном месте через `AvitoSettings.from_env()` и `AuthSettings.from_env()`. +- `AvitoClient.from_env()` является официальным factory method для инициализации клиента из environment. +- Resolution process environment и `.env` должен быть детерминированным и одинаковым для всех entry point. +- Значения из process environment имеют приоритет над `.env`. +- Поддерживаемые env-переменные и alias-имена должны быть задокументированы и считаться частью стабильного config contract. +- Отсутствие обязательных полей конфигурации должно валидироваться до первого HTTP-запроса через typed exceptions с понятными сообщениями. +- Сообщения и metadata ошибок конфигурации не должны содержать секретные значения. Пример: ```python @dataclass(slots=True, frozen=True) -class AvitoSettings: +class AuthSettings: client_id: str client_secret: str + refresh_token: str | None = None + + +@dataclass(slots=True, frozen=True) +class AvitoSettings: + auth: AuthSettings base_url: str = "https://api.avito.ru" + user_id: int | None = None timeout_seconds: float = 10.0 ``` +Минимально ожидаемые возможности config contract: + +- `AvitoSettings.from_env()`; +- `AuthSettings.from_env()`; +- `AvitoClient.from_env()`; +- явная валидация обязательных auth-полей; +- безопасный `debug_info()` contract без утечки `client_secret`, access token, refresh token и `Authorization` header. + +## Пагинация + +Публичное поведение lazy pagination должно быть зафиксировано как часть SDK contract. + +Правила: + +- list-методы, использующие lazy pagination, возвращают результат с list-like коллекцией `PaginatedList` в поле `items`; +- первая страница может быть уже загружена в момент получения результата; +- чтение первых `N` элементов не должно загружать все страницы сразу; +- итерация по первым `N` элементам должна выполнять только необходимое число page-запросов; +- полная материализация должна выполняться явным вызовом, например `items.materialize()`; +- пустая коллекция должна работать без лишних запросов; +- ошибка последующей страницы должна пробрасываться в момент чтения этой страницы; +- повторный доступ к уже загруженным элементам не должен инициировать повторный fetch, если кэширование объявлено частью контракта. + +Если поверх пагинации нужны дополнительные утилиты, они должны быть частью публичного SDK contract, а не внешними helper-функциями. + +## Сериализация + +Публичные модели SDK должны безопасно и единообразно сериализоваться без внешних helper-ов. + +Правила: + +- каждая публичная модель сериализуется стандартным SDK-методом; +- результат сериализации должен быть JSON-compatible; +- вложенные публичные модели должны сериализоваться рекурсивно; +- nullable и optional-поля сериализуются по правилам зафиксированного контракта; +- сериализация не должна раскрывать transport-объекты, служебные ссылки и внутренние mapper-поля. + ## Логирование Правила: - Логирование должно быть структурным и полезным для диагностики. -- Нельзя логировать `client_secret`, access token, полный authorization header. -- На уровне info/debug можно логировать endpoint, attempt number, latency, status code. +- Нельзя логировать `client_secret`, access token, refresh token, полный authorization header и иные секреты. +- На уровне info/debug можно логировать endpoint, attempt number, latency, status code и operation name. - Пользователь SDK должен иметь возможность отключить или перенаправить логирование. +- Диагностические снимки, например `debug_info()`, должны считаться безопасными по умолчанию. ## Докстринги и комментарии Правила: - Публичные классы и методы должны иметь короткие docstring с описанием контракта. +- Docstring публичного метода должен описывать возвращаемую SDK-модель и поведение на nullable/empty cases. - Комментарии используются только там, где нельзя выразить намерение кодом. - Комментарии не должны дублировать очевидное. @@ -321,6 +506,13 @@ Style guide ориентирован на код, который легко те - Нельзя захардкодить сетевые вызовы так, чтобы их нельзя было подменить в тестах. - Transport, auth provider и section clients должны тестироваться отдельно. - Mapping должен покрываться unit-тестами на реальных примерах Avito payload. +- Для unit/regression тестирования публичного SDK должен использоваться единый mock/fake transport. +- Для ключевых публичных моделей и результатов операций обязательны contract/snapshot tests. +- Для публичных read/write методов обязательны happy-path тесты через mock transport. +- Для write-методов с `dry_run` обязательны отдельные тесты, подтверждающие отсутствие write-вызова и идентичность payload builder. +- Typed error mapping должен быть покрыт отдельными тестами по статус-кодам и unsupported-сценариям. +- Lazy pagination должна покрываться regression-тестами на частичную итерацию, полную материализацию, пустую коллекцию и ошибку на последующих страницах. +- Сериализация публичных моделей должна покрываться отдельными contract-тестами. ## Импорты и зависимости @@ -340,6 +532,8 @@ Style guide ориентирован на код, который легко те - Широкого использования `Any`. - Обработки ошибок через `assert`. - Скрытых сетевых побочных эффектов в свойствах и dataclass. +- Утечки transport-layer shapes и mapper-деталей в публичные сигнатуры и модели. +- Неявного или недокументированного config resolution через environment. ## Практический вывод для текущего репозитория @@ -351,6 +545,9 @@ Style guide ориентирован на код, который легко те - разбить API по предметным пакетам вместо одной общей клиентской реализации; - ввести строгую конфигурацию `mypy`; - заменить сырые словари ответа на собственные типизированные модели; -- заменить `assert` на иерархию исключений SDK. +- закрепить `AvitoSettings` и `AuthSettings` как единственный config contract; +- закрепить стабильные публичные модели, serialization contract и lazy pagination semantics; +- унифицировать promotion read/write surface, включая `dry_run`; +- заменить `assert` на иерархию typed exceptions SDK и централизованный error mapping. Этот документ является базовым стандартом для всех следующих изменений в проекте. diff --git a/avito/__init__.py b/avito/__init__.py index 61b0a07..b0ed1b6 100644 --- a/avito/__init__.py +++ b/avito/__init__.py @@ -1,6 +1,19 @@ """Публичные экспорты пакета SDK для Avito.""" +from typing import TYPE_CHECKING + from avito.client import AvitoClient from avito.config import AvitoSettings -__all__ = ("AvitoClient", "AvitoSettings") +if TYPE_CHECKING: + from avito.auth.settings import AuthSettings + +__all__ = ("AuthSettings", "AvitoClient", "AvitoSettings") + + +def __getattr__(name: str) -> object: + if name == "AuthSettings": + from avito.auth.settings import AuthSettings + + return AuthSettings + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/avito/_env.py b/avito/_env.py new file mode 100644 index 0000000..7d3e461 --- /dev/null +++ b/avito/_env.py @@ -0,0 +1,63 @@ +"""Внутренние утилиты детерминированного чтения env и `.env`.""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from pathlib import Path + + +def read_dotenv(env_file: str | Path | None) -> dict[str, str]: + """Читает простой `.env` файл без побочных эффектов.""" + + if env_file is None: + return {} + + path = Path(env_file) + if not path.exists(): + return {} + + values: dict[str, str] = {} + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line.removeprefix("export ").strip() + if "=" not in line: + continue + key, value = line.split("=", 1) + cleaned_value = value.strip() + if len(cleaned_value) >= 2 and cleaned_value[0] == cleaned_value[-1]: + if cleaned_value[0] in {"'", '"'}: + cleaned_value = cleaned_value[1:-1] + values[key.strip()] = cleaned_value + return values + + +def resolve_env_aliases( + aliases_by_field: Mapping[str, tuple[str, ...]], + *, + env_file: str | Path | None, +) -> dict[str, str]: + """Разрешает env alias-имена с приоритетом process environment над `.env`.""" + + file_values = read_dotenv(env_file) + resolved: dict[str, str] = {} + + for field_name, aliases in aliases_by_field.items(): + for source in (os.environ, file_values): + value = _first_present(source, aliases) + if value is not None: + resolved[field_name] = value + break + + return resolved + + +def _first_present(source: Mapping[str, str], aliases: tuple[str, ...]) -> str | None: + for alias in aliases: + value = source.get(alias) + if value is not None and value != "": + return value + return None diff --git a/avito/accounts/client.py b/avito/accounts/client.py index 905d841..b4d7a26 100644 --- a/avito/accounts/client.py +++ b/avito/accounts/client.py @@ -28,6 +28,7 @@ OperationsHistoryResult, ) from avito.core import RequestContext, Transport +from avito.core.mapping import request_public_model @dataclass(slots=True) @@ -39,33 +40,36 @@ class AccountsClient: def get_self(self) -> AccountProfile: """Получает профиль авторизованного пользователя.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/core/v1/accounts/self", context=RequestContext("accounts.get_self"), + mapper=map_account_profile, ) - return map_account_profile(payload) def get_balance(self, *, user_id: int) -> AccountBalance: """Получает баланс аккаунта.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/core/v1/accounts/{user_id}/balance/", context=RequestContext("accounts.get_balance"), + mapper=map_account_balance, ) - return map_account_balance(payload) def get_operations_history(self, request: OperationsHistoryRequest) -> OperationsHistoryResult: """Получает историю операций пользователя.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/core/v1/accounts/operations_history/", context=RequestContext("accounts.get_operations_history", allow_retry=True), + mapper=map_operations_history, json_body=request.to_payload(), ) - return map_operations_history(payload) @dataclass(slots=True) @@ -77,54 +81,59 @@ class HierarchyClient: def get_status(self) -> AhUserStatus: """Получает статус пользователя в ИА.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/checkAhUserV1", context=RequestContext("accounts.hierarchy.get_status"), + mapper=map_ah_user_status, ) - return map_ah_user_status(payload) def list_employees(self) -> EmployeesResult: """Получает список сотрудников иерархии.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/getEmployeesV1", context=RequestContext("accounts.hierarchy.list_employees"), + mapper=map_employees, ) - return map_employees(payload) def list_company_phones(self) -> CompanyPhonesResult: """Получает список телефонов компании.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/listCompanyPhonesV1", context=RequestContext("accounts.hierarchy.list_company_phones"), + mapper=map_company_phones, ) - return map_company_phones(payload) def link_items(self, request: EmployeeItemLinkRequest) -> ActionResult: """Прикрепляет объявления к сотруднику.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/linkItemsV1", context=RequestContext("accounts.hierarchy.link_items", allow_retry=True), + mapper=map_action_result, json_body=request.to_payload(), ) - return map_action_result(payload) def list_items_by_employee(self, request: EmployeeItemsRequest) -> EmployeeItemsResult: """Получает список объявлений по сотруднику.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/listItemsByEmployeeIdV1", context=RequestContext("accounts.hierarchy.list_items_by_employee", allow_retry=True), + mapper=map_employee_items, json_body=request.to_payload(), ) - return map_employee_items(payload) __all__ = ("AccountsClient", "HierarchyClient") diff --git a/avito/accounts/domain.py b/avito/accounts/domain.py index b51fe83..59b857c 100644 --- a/avito/accounts/domain.py +++ b/avito/accounts/domain.py @@ -19,7 +19,7 @@ OperationsHistoryRequest, OperationsHistoryResult, ) -from avito.core import Transport +from avito.core import Transport, ValidationError @dataclass(slots=True, frozen=True) @@ -46,7 +46,7 @@ def get_balance(self, user_id: int | None = None) -> AccountBalance: resolved_user_id = user_id or (int(self.user_id) if self.user_id is not None else None) if resolved_user_id is None: - raise ValueError("Для получения баланса требуется `user_id`.") + raise ValidationError("Для операции требуется `user_id`.") return AccountsClient(self.transport).get_balance(user_id=resolved_user_id) def get_operations_history( diff --git a/avito/accounts/mappers.py b/avito/accounts/mappers.py index 11d8882..5d630d2 100644 --- a/avito/accounts/mappers.py +++ b/avito/accounts/mappers.py @@ -83,7 +83,7 @@ def map_account_profile(payload: object) -> AccountProfile: name=_as_str(data, "name", "title"), email=_as_str(data, "email"), phone=_as_str(data, "phone"), - raw_payload=data, + _payload=data, ) @@ -105,7 +105,7 @@ def map_account_balance(payload: object) -> AccountBalance: bonus=bonus, total=total, currency=_as_str(wallet_data, "currency"), - raw_payload=data, + _payload=data, ) @@ -121,14 +121,14 @@ def map_operations_history(payload: object) -> OperationsHistoryResult: operation_type=_as_str(item, "type", "operation_type", "operationType"), status=_as_str(item, "status"), description=_as_str(item, "description", "title"), - raw_payload=item, + _payload=item, ) for item in _as_list(data, "operations", "items", "result") ] return OperationsHistoryResult( operations=operations, total=_as_int(data, "total", "count"), - raw_payload=data, + _payload=data, ) @@ -140,7 +140,7 @@ def map_ah_user_status(payload: object) -> AhUserStatus: user_id=_as_int(data, "user_id", "userId", "id"), is_active=_as_bool(data, "is_active", "isActive", "active"), role=_as_str(data, "role", "status"), - raw_payload=data, + _payload=data, ) @@ -155,11 +155,11 @@ def map_employees(payload: object) -> EmployeesResult: name=_as_str(item, "name", "title"), phone=_as_str(item, "phone"), email=_as_str(item, "email"), - raw_payload=item, + _payload=item, ) for item in _as_list(data, "employees", "items", "result") ] - return EmployeesResult(items=items, total=_as_int(data, "total", "count"), raw_payload=data) + return EmployeesResult(items=items, total=_as_int(data, "total", "count"), _payload=data) def map_company_phones(payload: object) -> CompanyPhonesResult: @@ -171,11 +171,11 @@ def map_company_phones(payload: object) -> CompanyPhonesResult: id=_as_int(item, "id", "phone_id", "phoneId"), phone=_as_str(item, "phone", "value"), comment=_as_str(item, "comment", "description"), - raw_payload=item, + _payload=item, ) for item in _as_list(data, "phones", "items", "result") ] - return CompanyPhonesResult(items=items, raw_payload=data) + return CompanyPhonesResult(items=items, _payload=data) def map_employee_items(payload: object) -> EmployeeItemsResult: @@ -188,11 +188,11 @@ def map_employee_items(payload: object) -> EmployeeItemsResult: title=_as_str(item, "title"), status=_as_str(item, "status"), price=_as_float(item, "price"), - raw_payload=item, + _payload=item, ) for item in _as_list(data, "items", "result") ] - return EmployeeItemsResult(items=items, total=_as_int(data, "total", "count"), raw_payload=data) + return EmployeeItemsResult(items=items, total=_as_int(data, "total", "count"), _payload=data) def map_action_result(payload: object) -> ActionResult: @@ -202,8 +202,8 @@ def map_action_result(payload: object) -> ActionResult: data = cast(Payload, payload) success = bool(data.get("success", True)) message = _as_str(data, "message", "status") - return ActionResult(success=success, message=message, raw_payload=data) - return ActionResult(success=True, raw_payload={}) + return ActionResult(success=success, message=message, _payload=data) + return ActionResult(success=True, _payload={}) __all__ = ( diff --git a/avito/accounts/models.py b/avito/accounts/models.py index 0383da7..394c04e 100644 --- a/avito/accounts/models.py +++ b/avito/accounts/models.py @@ -5,16 +5,18 @@ from collections.abc import Mapping from dataclasses import dataclass, field +from avito.core.serialization import SerializableModel, enable_module_serialization + @dataclass(slots=True, frozen=True) -class AccountProfile: +class AccountProfile(SerializableModel): """Профиль авторизованного пользователя.""" id: int | None name: str | None email: str | None phone: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -26,7 +28,7 @@ class AccountBalance: bonus: float | None total: float | None currency: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -39,7 +41,7 @@ class OperationRecord: operation_type: str | None status: str | None description: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -67,12 +69,12 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class OperationsHistoryResult: +class OperationsHistoryResult(SerializableModel): """История операций пользователя.""" operations: list[OperationRecord] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -82,7 +84,7 @@ class AhUserStatus: user_id: int | None is_active: bool | None role: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -94,7 +96,7 @@ class Employee: name: str | None phone: str | None email: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -103,7 +105,7 @@ class EmployeesResult: items: list[Employee] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -113,7 +115,7 @@ class CompanyPhone: id: int | None phone: str | None comment: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -121,7 +123,7 @@ class CompanyPhonesResult: """Список телефонов компании.""" items: list[CompanyPhone] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -176,7 +178,7 @@ class EmployeeItem: title: str | None status: str | None price: float | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -185,7 +187,7 @@ class EmployeeItemsResult: items: list[EmployeeItem] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -194,7 +196,7 @@ class ActionResult: success: bool message: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) __all__ = ( @@ -214,3 +216,5 @@ class ActionResult: "OperationsHistoryRequest", "OperationsHistoryResult", ) + +enable_module_serialization(globals()) diff --git a/avito/ads/__init__.py b/avito/ads/__init__.py index 68f3e29..74d33db 100644 --- a/avito/ads/__init__.py +++ b/avito/ads/__init__.py @@ -10,6 +10,7 @@ DomainObject, ) from avito.ads.models import ( + AccountSpendings, ActionResult, AdItem, AdsListResult, @@ -27,9 +28,12 @@ AutoloadTreeResult, CallsStatsResult, CallStat, + CallStats, ItemAnalyticsResult, ItemStatsResult, LegacyAutoloadReport, + Listing, + ListingStats, SpendingsResult, UpdatePriceResult, UploadResult, @@ -38,6 +42,7 @@ ) __all__ = ( + "AccountSpendings", "ActionResult", "Ad", "AdItem", @@ -59,12 +64,15 @@ "AutoloadReportsResult", "AutoloadTreeNode", "AutoloadTreeResult", + "CallStats", "CallStat", "CallsStatsResult", "DomainObject", "ItemAnalyticsResult", "ItemStatsResult", "LegacyAutoloadReport", + "Listing", + "ListingStats", "SpendingsResult", "UpdatePriceResult", "UploadResult", diff --git a/avito/ads/client.py b/avito/ads/client.py index a2f6b05..4d1b810 100644 --- a/avito/ads/client.py +++ b/avito/ads/client.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from avito.ads.mappers import ( @@ -23,7 +24,6 @@ map_spendings, map_update_price_result, map_upload_result, - map_vas_apply_result, map_vas_prices, ) from avito.ads.models import ( @@ -52,11 +52,13 @@ UpdatePriceResult, UploadByUrlRequest, UploadResult, - VasApplyResult, VasPricesRequest, VasPricesResult, ) -from avito.core import JsonPage, Paginator, RequestContext, Transport +from avito.core import JsonPage, Paginator, RequestContext, Transport, ValidationError +from avito.core.mapping import request_public_model +from avito.promotion.mappers import map_promotion_action +from avito.promotion.models import PromotionActionResult @dataclass(slots=True) @@ -68,12 +70,13 @@ class AdsClient: def get_item(self, *, user_id: int, item_id: int) -> AdItem: """Получает одно объявление.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/core/v1/accounts/{user_id}/items/{item_id}/", context=RequestContext("ads.get_item"), + mapper=map_ad_item, ) - return map_ad_item(payload) def list_items( self, @@ -86,10 +89,12 @@ def list_items( """Получает список объявлений пользователя.""" start_offset = offset if offset is not None else 0 if limit is not None else None - payload = self.transport.request_json( + result = request_public_model( + self.transport, "GET", "/core/v1/items", context=RequestContext("ads.list_items"), + mapper=map_ads_list, params={ "user_id": user_id, "status": status, @@ -97,7 +102,6 @@ def list_items( "offset": start_offset, }, ) - result = map_ads_list(payload) page_size = limit if limit and limit > 0 else len(result.items) resolved_offset = start_offset or 0 @@ -122,7 +126,7 @@ def list_items( per_page=page_size, ), ) - return AdsListResult(items=paginated_items, total=result.total, raw_payload=result.raw_payload) + return AdsListResult(items=paginated_items, total=result.total) def _fetch_ads_page( self, @@ -133,13 +137,15 @@ def _fetch_ads_page( page_size: int, ) -> JsonPage[AdItem]: if page is None: - raise ValueError("Постраничная загрузка объявлений требует номера страницы.") + raise ValidationError("Для операции требуется `page`.") offset = (page - 1) * page_size - payload = self.transport.request_json( + result = request_public_model( + self.transport, "GET", "/core/v1/items", context=RequestContext("ads.list_items"), + mapper=map_ads_list, params={ "user_id": user_id, "status": status, @@ -147,7 +153,6 @@ def _fetch_ads_page( "offset": offset, }, ) - result = map_ads_list(payload) return JsonPage( items=list(result.items), total=result.total, @@ -158,13 +163,14 @@ def _fetch_ads_page( def update_price(self, *, item_id: int, price: UpdatePriceRequest) -> UpdatePriceResult: """Обновляет цену объявления.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", f"/core/v1/items/{item_id}/update_price", context=RequestContext("ads.update_price", allow_retry=True), + mapper=map_update_price_result, json_body=price.to_payload(), ) - return map_update_price_result(payload) @dataclass(slots=True) @@ -176,46 +182,50 @@ class StatsClient: def get_calls_stats(self, *, user_id: int, request: CallsStatsRequest) -> CallsStatsResult: """Получает статистику звонков.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", f"/core/v1/accounts/{user_id}/calls/stats/", context=RequestContext("ads.stats.calls", allow_retry=True), + mapper=map_calls_stats, json_body=request.to_payload(), ) - return map_calls_stats(payload) def get_item_stats(self, *, user_id: int, request: ItemStatsRequest) -> ItemStatsResult: """Получает статистику по списку объявлений.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", f"/stats/v1/accounts/{user_id}/items", context=RequestContext("ads.stats.items", allow_retry=True), + mapper=map_item_stats, json_body=request.to_payload(), ) - return map_item_stats(payload) def get_item_analytics(self, *, user_id: int, request: ItemStatsRequest) -> ItemAnalyticsResult: """Получает аналитику по профилю.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", f"/stats/v2/accounts/{user_id}/items", context=RequestContext("ads.stats.analytics", allow_retry=True), + mapper=map_item_analytics, json_body=request.to_payload(), ) - return map_item_analytics(payload) def get_account_spendings(self, *, user_id: int, request: ItemStatsRequest) -> SpendingsResult: """Получает статистику расходов профиля.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", f"/stats/v2/accounts/{user_id}/spendings", context=RequestContext("ads.stats.spendings", allow_retry=True), + mapper=map_spendings, json_body=request.to_payload(), ) - return map_spendings(payload) @dataclass(slots=True) @@ -227,50 +237,91 @@ class VasClient: def get_prices(self, *, user_id: int, request: VasPricesRequest) -> VasPricesResult: """Получает цены VAS и доступные услуги продвижения.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", f"/core/v1/accounts/{user_id}/vas/prices", context=RequestContext("ads.vas.prices", allow_retry=True), + mapper=map_vas_prices, json_body=request.to_payload(), ) - return map_vas_prices(payload) def apply_item_vas( - self, *, user_id: int, item_id: int, request: ApplyVasRequest - ) -> ActionResult: + self, + *, + user_id: int, + item_id: int, + request: ApplyVasRequest, + action: str = "apply_vas", + target: Mapping[str, object] | None = None, + request_payload: Mapping[str, object] | None = None, + ) -> PromotionActionResult: """Применяет дополнительные услуги к объявлению.""" + payload_to_send = dict(request_payload) if request_payload is not None else request.to_payload() payload = self.transport.request_json( "PUT", f"/core/v1/accounts/{user_id}/items/{item_id}/vas", context=RequestContext("ads.vas.apply_item_vas", allow_retry=True), - json_body=request.to_payload(), + json_body=payload_to_send, + ) + return map_promotion_action( + payload, + action=action, + target=target or {"item_id": item_id, "user_id": user_id}, + request_payload=payload_to_send, ) - return map_action_result(payload) def apply_item_vas_package( - self, *, user_id: int, item_id: int, request: ApplyVasPackageRequest - ) -> ActionResult: + self, + *, + user_id: int, + item_id: int, + request: ApplyVasPackageRequest, + action: str = "apply_vas_package", + target: Mapping[str, object] | None = None, + request_payload: Mapping[str, object] | None = None, + ) -> PromotionActionResult: """Применяет пакет дополнительных услуг.""" + payload_to_send = dict(request_payload) if request_payload is not None else request.to_payload() payload = self.transport.request_json( "PUT", f"/core/v2/accounts/{user_id}/items/{item_id}/vas_packages", context=RequestContext("ads.vas.apply_item_vas_package", allow_retry=True), - json_body=request.to_payload(), + json_body=payload_to_send, + ) + return map_promotion_action( + payload, + action=action, + target=target or {"item_id": item_id, "user_id": user_id}, + request_payload=payload_to_send, ) - return map_action_result(payload) - def apply_vas_v2(self, *, item_id: int, request: ApplyVasRequest) -> VasApplyResult: + def apply_vas_v2( + self, + *, + item_id: int, + request: ApplyVasRequest, + action: str = "apply_vas_v2", + target: Mapping[str, object] | None = None, + request_payload: Mapping[str, object] | None = None, + ) -> PromotionActionResult: """Применяет услуги продвижения через v2 endpoint.""" + payload_to_send = dict(request_payload) if request_payload is not None else request.to_payload() payload = self.transport.request_json( "PUT", f"/core/v2/items/{item_id}/vas/", context=RequestContext("ads.vas.apply_v2", allow_retry=True), - json_body=request.to_payload(), + json_body=payload_to_send, + ) + return map_promotion_action( + payload, + action=action, + target=target or {"item_id": item_id}, + request_payload=payload_to_send, ) - return map_vas_apply_result(payload) @dataclass(slots=True) @@ -282,140 +333,153 @@ class AutoloadClient: def get_profile(self) -> AutoloadProfileSettings: """Получает профиль пользователя автозагрузки.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/autoload/v2/profile", context=RequestContext("ads.autoload.get_profile"), + mapper=map_autoload_profile, ) - return map_autoload_profile(payload) def save_profile(self, request: AutoloadProfileUpdateRequest) -> ActionResult: """Создает или редактирует профиль автозагрузки.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/autoload/v2/profile", context=RequestContext("ads.autoload.save_profile", allow_retry=True), + mapper=map_action_result, json_body=request.to_payload(), ) - return map_action_result(payload) def upload_by_url(self, request: UploadByUrlRequest) -> UploadResult: """Запускает загрузку файла по ссылке.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/autoload/v1/upload", context=RequestContext("ads.autoload.upload_by_url", allow_retry=True), + mapper=map_upload_result, json_body=request.to_payload(), ) - return map_upload_result(payload) def get_node_fields(self, *, node_slug: str) -> AutoloadFieldsResult: """Получает поля категории по slug.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/autoload/v1/user-docs/node/{node_slug}/fields", context=RequestContext("ads.autoload.get_node_fields"), + mapper=map_autoload_fields, ) - return map_autoload_fields(payload) def get_tree(self) -> AutoloadTreeResult: """Получает дерево категорий автозагрузки.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/autoload/v1/user-docs/tree", context=RequestContext("ads.autoload.get_tree"), + mapper=map_autoload_tree, ) - return map_autoload_tree(payload) def get_ad_ids_by_avito_ids(self, *, avito_ids: list[int]) -> IdMappingResult: """Получает ad ids по avito ids.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/autoload/v2/items/ad_ids", context=RequestContext("ads.autoload.get_ad_ids_by_avito_ids"), + mapper=map_id_mapping, params={"avito_ids": ",".join(str(item) for item in avito_ids)}, ) - return map_id_mapping(payload) def get_avito_ids_by_ad_ids(self, *, ad_ids: list[int]) -> IdMappingResult: """Получает avito ids по ad ids.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/autoload/v2/items/avito_ids", context=RequestContext("ads.autoload.get_avito_ids_by_ad_ids"), + mapper=map_id_mapping, params={"ad_ids": ",".join(str(item) for item in ad_ids)}, ) - return map_id_mapping(payload) def list_reports( self, *, limit: int | None = None, offset: int | None = None ) -> AutoloadReportsResult: """Получает список отчетов автозагрузки.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/autoload/v2/reports", context=RequestContext("ads.autoload.list_reports"), + mapper=map_autoload_reports, params={"limit": limit, "offset": offset}, ) - return map_autoload_reports(payload) def get_items_info(self, *, item_ids: list[int]) -> AutoloadReportItemsResult: """Получает объявления автозагрузки по ID.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/autoload/v2/reports/items", context=RequestContext("ads.autoload.get_items_info"), + mapper=map_autoload_report_items, params={"item_ids": ",".join(str(item) for item in item_ids)}, ) - return map_autoload_report_items(payload) def get_report(self, *, report_id: int) -> AutoloadReportDetails: """Получает статистику по конкретной выгрузке v3.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/autoload/v3/reports/{report_id}", context=RequestContext("ads.autoload.get_report"), + mapper=map_autoload_report_details, ) - return map_autoload_report_details(payload) def get_last_completed_report(self) -> AutoloadReportDetails: """Получает статистику по последней выгрузке v3.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/autoload/v3/reports/last_completed_report", context=RequestContext("ads.autoload.get_last_completed_report"), + mapper=map_autoload_report_details, ) - return map_autoload_report_details(payload) def get_report_items(self, *, report_id: int) -> AutoloadReportItemsResult: """Получает все объявления из конкретной выгрузки.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/autoload/v2/reports/{report_id}/items", context=RequestContext("ads.autoload.get_report_items"), + mapper=map_autoload_report_items, ) - return map_autoload_report_items(payload) def get_report_fees(self, *, report_id: int) -> AutoloadFeesResult: """Получает списания по объявлениям выгрузки.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/autoload/v2/reports/{report_id}/items/fees", context=RequestContext("ads.autoload.get_report_fees"), + mapper=map_autoload_fees, ) - return map_autoload_fees(payload) @dataclass(slots=True) @@ -427,43 +491,47 @@ class AutoloadLegacyClient: def get_profile(self) -> AutoloadProfileSettings: """Получает legacy профиль автозагрузки.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/autoload/v1/profile", context=RequestContext("ads.autoload_legacy.get_profile"), + mapper=map_autoload_profile, ) - return map_autoload_profile(payload) def save_profile(self, request: AutoloadProfileUpdateRequest) -> ActionResult: """Создает или редактирует legacy профиль автозагрузки.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/autoload/v1/profile", context=RequestContext("ads.autoload_legacy.save_profile", allow_retry=True), + mapper=map_action_result, json_body=request.to_payload(), ) - return map_action_result(payload) def get_last_completed_report(self) -> LegacyAutoloadReport: """Получает статистику по последней выгрузке legacy v2.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/autoload/v2/reports/last_completed_report", context=RequestContext("ads.autoload_legacy.get_last_completed_report"), + mapper=map_legacy_autoload_report, ) - return map_legacy_autoload_report(payload) def get_report(self, *, report_id: int) -> LegacyAutoloadReport: """Получает статистику по конкретной выгрузке legacy v2.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/autoload/v2/reports/{report_id}", context=RequestContext("ads.autoload_legacy.get_report"), + mapper=map_legacy_autoload_report, ) - return map_legacy_autoload_report(payload) __all__ = ("AdsClient", "AutoloadClient", "AutoloadLegacyClient", "StatsClient", "VasClient") diff --git a/avito/ads/domain.py b/avito/ads/domain.py index 9a9011b..4e3c838 100644 --- a/avito/ads/domain.py +++ b/avito/ads/domain.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from dataclasses import dataclass from avito.ads.client import AdsClient, AutoloadClient, AutoloadLegacyClient, StatsClient, VasClient @@ -32,11 +32,43 @@ UpdatePriceResult, UploadByUrlRequest, UploadResult, - VasApplyResult, VasPricesRequest, VasPricesResult, ) -from avito.core import Transport +from avito.core import Transport, ValidationError +from avito.promotion.models import PromotionActionResult + + +def _validate_non_empty_items(name: str, items: Sequence[object]) -> None: + if not items: + raise ValidationError(f"`{name}` must contain at least one item.") + + +def _validate_non_empty_string(name: str, value: str) -> None: + if not value.strip(): + raise ValidationError(f"`{name}` must be a non-empty string.") + + +def _validate_string_items(name: str, values: Sequence[str]) -> None: + _validate_non_empty_items(name, values) + for index, value in enumerate(values): + _validate_non_empty_string(f"{name}[{index}]", value) + + +def _preview_result( + *, + action: str, + target: Mapping[str, object], + request_payload: Mapping[str, object], +) -> PromotionActionResult: + return PromotionActionResult( + action=action, + target=dict(target), + status="preview", + applied=False, + request_payload=dict(request_payload), + details={"validated": True}, + ) @dataclass(slots=True, frozen=True) @@ -99,12 +131,12 @@ def get_stats( def _require_item_id(self) -> int: if self.resource_id is None: - raise ValueError("Для операции требуется `item_id`.") + raise ValidationError("Для операции требуется `item_id`.") return int(self.resource_id) def _require_ids(self) -> tuple[int, int]: if self.resource_id is None or self.user_id is None: - raise ValueError("Для операции требуются `item_id` и `user_id`.") + raise ValidationError("Для операции требуются `item_id` и `user_id`.") return int(self.resource_id), int(self.user_id) @@ -209,7 +241,7 @@ def get_account_spendings( def _require_user_id(self) -> int: if self.user_id is None: - raise ValueError("Для статистики требуется `user_id`.") + raise ValidationError("Для операции требуется `user_id`.") return int(self.user_id) @@ -231,42 +263,97 @@ def get_vas_prices( request=VasPricesRequest(item_ids=item_ids, location_id=location_id), ) - def apply_vas(self, *, codes: list[str]) -> ActionResult: + def apply_vas( + self, + *, + codes: list[str], + dry_run: bool = False, + ) -> PromotionActionResult: """Применяет дополнительные услуги к объявлению.""" item_id, user_id = self._require_ids() + _validate_string_items("codes", codes) + request = ApplyVasRequest(codes=codes) + request_payload = request.to_payload() + target = {"item_id": item_id, "user_id": user_id} + if dry_run: + return _preview_result( + action="apply_vas", + target=target, + request_payload=request_payload, + ) return VasClient(self.transport).apply_item_vas( user_id=user_id, item_id=item_id, - request=ApplyVasRequest(codes=codes), + request=request, + action="apply_vas", + target=target, + request_payload=request_payload, ) - def apply_vas_package(self, *, package_code: str) -> ActionResult: + def apply_vas_package( + self, + *, + package_code: str, + dry_run: bool = False, + ) -> PromotionActionResult: """Применяет пакет дополнительных услуг.""" item_id, user_id = self._require_ids() + _validate_non_empty_string("package_code", package_code) + request = ApplyVasPackageRequest(package_code=package_code) + request_payload = request.to_payload() + target = {"item_id": item_id, "user_id": user_id} + if dry_run: + return _preview_result( + action="apply_vas_package", + target=target, + request_payload=request_payload, + ) return VasClient(self.transport).apply_item_vas_package( user_id=user_id, item_id=item_id, - request=ApplyVasPackageRequest(package_code=package_code), + request=request, + action="apply_vas_package", + target=target, + request_payload=request_payload, ) - def apply_vas_v2(self, *, codes: list[str]) -> VasApplyResult: + def apply_vas_v2( + self, + *, + codes: list[str], + dry_run: bool = False, + ) -> PromotionActionResult: """Применяет услуги продвижения через v2 endpoint.""" item_id = self._require_item_id() + _validate_string_items("codes", codes) + request = ApplyVasRequest(codes=codes) + request_payload = request.to_payload() + target = {"item_id": item_id} + if dry_run: + return _preview_result( + action="apply_vas_v2", + target=target, + request_payload=request_payload, + ) return VasClient(self.transport).apply_vas_v2( - item_id=item_id, request=ApplyVasRequest(codes=codes) + item_id=item_id, + request=request, + action="apply_vas_v2", + target=target, + request_payload=request_payload, ) def _require_item_id(self) -> int: if self.resource_id is None: - raise ValueError("Для операции требуется `item_id`.") + raise ValidationError("Для операции требуется `item_id`.") return int(self.resource_id) def _require_user_id(self) -> int: if self.user_id is None: - raise ValueError("Для операции требуется `user_id`.") + raise ValidationError("Для операции требуется `user_id`.") return int(self.user_id) def _require_ids(self) -> tuple[int, int]: @@ -368,7 +455,7 @@ def get_items_info(self, *, item_ids: Sequence[int]) -> AutoloadReportItemsResul def _require_report_id(self) -> int: if self.resource_id is None: - raise ValueError("Для операции требуется `report_id`.") + raise ValidationError("Для операции требуется `report_id`.") return int(self.resource_id) @@ -412,7 +499,7 @@ def get_report(self) -> LegacyAutoloadReport: def _require_report_id(self) -> int: if self.resource_id is None: - raise ValueError("Для операции требуется `report_id`.") + raise ValidationError("Для операции требуется `report_id`.") return int(self.resource_id) diff --git a/avito/ads/mappers.py b/avito/ads/mappers.py index 6787f26..34ed058 100644 --- a/avito/ads/mappers.py +++ b/avito/ads/mappers.py @@ -103,7 +103,7 @@ def map_ad_item(payload: object) -> AdItem: status=_str(data, "status"), price=_float(data, "price"), url=_str(data, "url", "link"), - raw_payload=data, + _payload=data, ) @@ -112,7 +112,7 @@ def map_ads_list(payload: object) -> AdsListResult: data = _expect_mapping(payload) items = [map_ad_item(item) for item in _list(data, "items", "result", "resources")] - return AdsListResult(items=items, total=_int(data, "total", "count"), raw_payload=data) + return AdsListResult(items=items, total=_int(data, "total", "count"), _payload=data) def map_update_price_result(payload: object) -> UpdatePriceResult: @@ -123,7 +123,7 @@ def map_update_price_result(payload: object) -> UpdatePriceResult: item_id=_int(data, "item_id", "itemId", "id"), price=_float(data, "price"), status=_str(data, "status", "result"), - raw_payload=data, + _payload=data, ) @@ -137,11 +137,11 @@ def map_calls_stats(payload: object) -> CallsStatsResult: calls=_int(item, "calls", "total"), answered_calls=_int(item, "answered_calls", "answeredCalls"), missed_calls=_int(item, "missed_calls", "missedCalls"), - raw_payload=item, + _payload=item, ) for item in _list(data, "items", "result", "stats") ] - return CallsStatsResult(items=items, raw_payload=data) + return CallsStatsResult(items=items, _payload=data) def _map_item_stat(item: Payload) -> ItemStatsRecord: @@ -150,7 +150,7 @@ def _map_item_stat(item: Payload) -> ItemStatsRecord: views=_int(item, "views", "impressions"), contacts=_int(item, "contacts", "contacts_total", "contactsTotal"), favorites=_int(item, "favorites", "favorites_total", "favoritesTotal"), - raw_payload=item, + _payload=item, ) @@ -160,7 +160,7 @@ def map_item_stats(payload: object) -> ItemStatsResult: data = _expect_mapping(payload) return ItemStatsResult( items=[_map_item_stat(item) for item in _list(data, "items", "result", "stats")], - raw_payload=data, + _payload=data, ) @@ -171,7 +171,7 @@ def map_item_analytics(payload: object) -> ItemAnalyticsResult: return ItemAnalyticsResult( items=[_map_item_stat(item) for item in _list(data, "items", "result", "stats")], period=_str(data, "period"), - raw_payload=data, + _payload=data, ) @@ -184,14 +184,14 @@ def map_spendings(payload: object) -> SpendingsResult: item_id=_int(item, "item_id", "itemId", "id"), amount=_float(item, "amount", "price", "cost"), service=_str(item, "service", "serviceType", "type"), - raw_payload=item, + _payload=item, ) for item in _list(data, "items", "result", "spendings") ] total = _float(data, "total") if total is None: total = sum(item.amount for item in items if item.amount is not None) or None - return SpendingsResult(items=items, total=total, raw_payload=data) + return SpendingsResult(items=items, total=total, _payload=data) def map_vas_prices(payload: object) -> VasPricesResult: @@ -204,11 +204,11 @@ def map_vas_prices(payload: object) -> VasPricesResult: title=_str(item, "title", "name"), price=_float(item, "price", "amount"), is_available=_bool(item, "is_available", "isAvailable", "available"), - raw_payload=item, + _payload=item, ) for item in _list(data, "items", "services", "result") ] - return VasPricesResult(items=items, raw_payload=data) + return VasPricesResult(items=items, _payload=data) def map_vas_apply_result(payload: object) -> VasApplyResult: @@ -218,7 +218,7 @@ def map_vas_apply_result(payload: object) -> VasApplyResult: return VasApplyResult( success=bool(data.get("success", True)), status=_str(data, "status", "result", "message"), - raw_payload=data, + _payload=data, ) @@ -230,7 +230,7 @@ def map_autoload_profile(payload: object) -> AutoloadProfileSettings: user_id=_int(data, "user_id", "userId", "id"), is_enabled=_bool(data, "is_enabled", "isEnabled", "enabled"), upload_url=_str(data, "upload_url", "uploadUrl", "url"), - raw_payload=data, + _payload=data, ) @@ -241,7 +241,7 @@ def map_upload_result(payload: object) -> UploadResult: return UploadResult( success=bool(data.get("success", True)), report_id=_int(data, "report_id", "reportId", "id"), - raw_payload=data, + _payload=data, ) @@ -255,11 +255,11 @@ def map_autoload_fields(payload: object) -> AutoloadFieldsResult: title=_str(item, "title", "name"), type=_str(item, "type"), required=_bool(item, "required", "is_required", "isRequired"), - raw_payload=item, + _payload=item, ) for item in _list(data, "fields", "items", "result") ] - return AutoloadFieldsResult(items=items, raw_payload=data) + return AutoloadFieldsResult(items=items, _payload=data) def _map_tree_node(payload: Payload) -> AutoloadTreeNode: @@ -267,7 +267,7 @@ def _map_tree_node(payload: Payload) -> AutoloadTreeNode: slug=_str(payload, "slug", "code", "id"), title=_str(payload, "title", "name"), children=[_map_tree_node(item) for item in _list(payload, "children", "items")], - raw_payload=payload, + _payload=payload, ) @@ -276,7 +276,7 @@ def map_autoload_tree(payload: object) -> AutoloadTreeResult: data = _expect_mapping(payload) items = [_map_tree_node(item) for item in _list(data, "tree", "items", "result")] - return AutoloadTreeResult(items=items, raw_payload=data) + return AutoloadTreeResult(items=items, _payload=data) def map_id_mapping(payload: object) -> IdMappingResult: @@ -286,7 +286,7 @@ def map_id_mapping(payload: object) -> IdMappingResult: mappings: list[tuple[int | None, int | None]] = [] for item in _list(data, "items", "result", "mappings"): mappings.append((_int(item, "ad_id", "adId"), _int(item, "avito_id", "avitoId"))) - return IdMappingResult(mappings=mappings, raw_payload=data) + return IdMappingResult(mappings=mappings, _payload=data) def _map_report_summary(item: Payload) -> AutoloadReportSummary: @@ -296,7 +296,7 @@ def _map_report_summary(item: Payload) -> AutoloadReportSummary: created_at=_str(item, "created_at", "createdAt"), finished_at=_str(item, "finished_at", "finishedAt"), processed_items=_int(item, "processed_items", "processedItems", "items"), - raw_payload=item, + _payload=item, ) @@ -307,7 +307,7 @@ def map_autoload_reports(payload: object) -> AutoloadReportsResult: return AutoloadReportsResult( items=[_map_report_summary(item) for item in _list(data, "reports", "items", "result")], total=_int(data, "total", "count"), - raw_payload=data, + _payload=data, ) @@ -322,7 +322,7 @@ def map_autoload_report_details(payload: object) -> AutoloadReportDetails: finished_at=_str(data, "finished_at", "finishedAt"), errors_count=_int(data, "errors_count", "errorsCount"), warnings_count=_int(data, "warnings_count", "warningsCount"), - raw_payload=data, + _payload=data, ) @@ -333,7 +333,7 @@ def map_legacy_autoload_report(payload: object) -> LegacyAutoloadReport: return LegacyAutoloadReport( report_id=_int(data, "report_id", "reportId", "id"), status=_str(data, "status"), - raw_payload=data, + _payload=data, ) @@ -347,12 +347,12 @@ def map_autoload_report_items(payload: object) -> AutoloadReportItemsResult: avito_id=_int(item, "avito_id", "avitoId"), status=_str(item, "status"), title=_str(item, "title"), - raw_payload=item, + _payload=item, ) for item in _list(data, "items", "result") ] return AutoloadReportItemsResult( - items=items, total=_int(data, "total", "count"), raw_payload=data + items=items, total=_int(data, "total", "count"), _payload=data ) @@ -365,14 +365,14 @@ def map_autoload_fees(payload: object) -> AutoloadFeesResult: item_id=_int(item, "item_id", "itemId", "id"), amount=_float(item, "amount", "price", "cost"), service=_str(item, "service", "serviceType", "type"), - raw_payload=item, + _payload=item, ) for item in _list(data, "items", "result", "fees") ] total = _float(data, "total") if total is None: total = sum(item.amount for item in items if item.amount is not None) or None - return AutoloadFeesResult(items=items, total=total, raw_payload=data) + return AutoloadFeesResult(items=items, total=total, _payload=data) def map_action_result(payload: object) -> ActionResult: @@ -383,9 +383,9 @@ def map_action_result(payload: object) -> ActionResult: return ActionResult( success=bool(data.get("success", True)), message=_str(data, "message", "status"), - raw_payload=data, + _payload=data, ) - return ActionResult(success=True, raw_payload={}) + return ActionResult(success=True, _payload={}) __all__ = ( diff --git a/avito/ads/models.py b/avito/ads/models.py index 13d803e..4dae8bb 100644 --- a/avito/ads/models.py +++ b/avito/ads/models.py @@ -5,9 +5,11 @@ from collections.abc import Mapping from dataclasses import dataclass, field +from avito.core.serialization import SerializableModel, enable_module_serialization + @dataclass(slots=True, frozen=True) -class AdItem: +class AdItem(SerializableModel): """Объявление пользователя.""" id: int | None @@ -17,16 +19,16 @@ class AdItem: status: str | None price: float | None url: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class AdsListResult: +class AdsListResult(SerializableModel): """Результат списка объявлений.""" items: list[AdItem] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -48,7 +50,7 @@ class UpdatePriceResult: item_id: int | None price: float | None status: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -74,22 +76,22 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class CallStat: +class CallStat(SerializableModel): """Статистика звонков по объявлению.""" item_id: int | None calls: int | None answered_calls: int | None missed_calls: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CallsStatsResult: +class CallsStatsResult(SerializableModel): """Статистика звонков по набору объявлений.""" items: list[CallStat] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -117,22 +119,22 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class ItemStatsRecord: +class ItemStatsRecord(SerializableModel): """Статистические показатели объявления.""" item_id: int | None views: int | None contacts: int | None favorites: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class ItemStatsResult: +class ItemStatsResult(SerializableModel): """Статистика по списку объявлений.""" items: list[ItemStatsRecord] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -141,26 +143,26 @@ class ItemAnalyticsResult: items: list[ItemStatsRecord] period: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class SpendingRecord: +class SpendingRecord(SerializableModel): """Запись статистики расходов.""" item_id: int | None amount: float | None service: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class SpendingsResult: +class SpendingsResult(SerializableModel): """Статистика расходов профиля.""" items: list[SpendingRecord] total: float | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -171,7 +173,7 @@ class VasPrice: title: str | None price: float | None is_available: bool | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -199,7 +201,7 @@ class VasPricesResult: """Список цен и доступных услуг продвижения.""" items: list[VasPrice] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -208,7 +210,7 @@ class VasApplyResult: success: bool status: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -242,7 +244,7 @@ class AutoloadProfileSettings: user_id: int | None is_enabled: bool | None upload_url: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -285,7 +287,7 @@ class UploadResult: success: bool report_id: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -296,7 +298,7 @@ class AutoloadField: title: str | None type: str | None required: bool | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -304,7 +306,7 @@ class AutoloadFieldsResult: """Список полей категории автозагрузки.""" items: list[AutoloadField] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -314,7 +316,7 @@ class AutoloadTreeNode: slug: str | None title: str | None children: list[AutoloadTreeNode] = field(default_factory=list) - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -322,7 +324,7 @@ class AutoloadTreeResult: """Дерево категорий автозагрузки.""" items: list[AutoloadTreeNode] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -330,7 +332,7 @@ class IdMappingResult: """Сопоставление идентификаторов объявлений.""" mappings: list[tuple[int | None, int | None]] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -342,7 +344,7 @@ class AutoloadReportSummary: created_at: str | None finished_at: str | None processed_items: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -351,7 +353,7 @@ class AutoloadReportsResult: items: list[AutoloadReportSummary] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -362,7 +364,7 @@ class AutoloadReportItem: avito_id: int | None status: str | None title: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -371,7 +373,7 @@ class AutoloadReportItemsResult: items: list[AutoloadReportItem] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -381,7 +383,7 @@ class AutoloadFee: item_id: int | None amount: float | None service: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -390,7 +392,7 @@ class AutoloadFeesResult: items: list[AutoloadFee] total: float | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -403,7 +405,7 @@ class AutoloadReportDetails: finished_at: str | None errors_count: int | None warnings_count: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -412,7 +414,7 @@ class LegacyAutoloadReport: report_id: int | None status: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -421,10 +423,17 @@ class ActionResult: success: bool message: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) + + +Listing = AdItem +ListingStats = ItemStatsRecord +CallStats = CallStat +AccountSpendings = SpendingsResult __all__ = ( + "AccountSpendings", "ActionResult", "AdItem", "AdsListResult", @@ -443,6 +452,7 @@ class ActionResult: "AutoloadReportsResult", "AutoloadTreeNode", "AutoloadTreeResult", + "CallStats", "CallStat", "CallsStatsRequest", "CallsStatsResult", @@ -452,6 +462,8 @@ class ActionResult: "ItemStatsRequest", "ItemStatsResult", "LegacyAutoloadReport", + "Listing", + "ListingStats", "SpendingRecord", "SpendingsResult", "UpdatePriceRequest", @@ -463,3 +475,5 @@ class ActionResult: "VasPricesRequest", "VasPricesResult", ) + +enable_module_serialization(globals()) diff --git a/avito/auth/mappers.py b/avito/auth/mappers.py index 00836dd..e489366 100644 --- a/avito/auth/mappers.py +++ b/avito/auth/mappers.py @@ -39,7 +39,7 @@ def map_token_response(payload: object, *, now: datetime | None = None) -> Token ), refresh_token=refresh_token, scope=payload.get("scope") if isinstance(payload.get("scope"), str) else None, - raw_payload=payload, + _payload=payload, ) diff --git a/avito/auth/models.py b/avito/auth/models.py index c0fa8cc..262065c 100644 --- a/avito/auth/models.py +++ b/avito/auth/models.py @@ -28,7 +28,7 @@ class TokenResponse: access_token: AccessToken refresh_token: str | None = None scope: str | None = None - raw_payload: Mapping[str, object] | None = None + _payload: Mapping[str, object] | None = None @dataclass(slots=True, frozen=True) diff --git a/avito/auth/settings.py b/avito/auth/settings.py index 221d48e..8310d6e 100644 --- a/avito/auth/settings.py +++ b/avito/auth/settings.py @@ -2,59 +2,169 @@ from __future__ import annotations -from pydantic import AliasChoices, Field -from pydantic_settings import BaseSettings, SettingsConfigDict +from pathlib import Path +from typing import ClassVar +from pydantic import AliasChoices, BaseModel, ConfigDict, Field -class AuthSettings(BaseSettings): - """Настройки OAuth и служебных токенов для transport-слоя.""" +from avito._env import resolve_env_aliases +from avito.core.exceptions import ConfigurationError - model_config = SettingsConfigDict( - env_prefix="AVITO_", - env_file=".env", + +class AuthSettings(BaseModel): + """Единственный публичный контракт OAuth-конфигурации SDK.""" + + ENV_ALIASES: ClassVar[dict[str, tuple[str, ...]]] = { + "client_id": ("AVITO_AUTH__CLIENT_ID", "AVITO_CLIENT_ID", "CLIENT_ID"), + "client_secret": ( + "AVITO_AUTH__CLIENT_SECRET", + "AVITO_CLIENT_SECRET", + "AVITO_SECRET", + "CLIENT_SECRET", + "SECRET", + ), + "scope": ("AVITO_AUTH__SCOPE", "AVITO_SCOPE", "SCOPE"), + "refresh_token": ( + "AVITO_AUTH__REFRESH_TOKEN", + "AVITO_REFRESH_TOKEN", + "REFRESH_TOKEN", + ), + "token_url": ("AVITO_AUTH__TOKEN_URL", "AVITO_TOKEN_URL", "TOKEN_URL"), + "legacy_token_url": ( + "AVITO_AUTH__LEGACY_TOKEN_URL", + "AVITO_LEGACY_TOKEN_URL", + "LEGACY_TOKEN_URL", + ), + "autoteka_token_url": ( + "AVITO_AUTH__AUTOTEKA_TOKEN_URL", + "AVITO_AUTOTEKA_TOKEN_URL", + "AUTOTEKA_TOKEN_URL", + ), + "autoteka_client_id": ( + "AVITO_AUTH__AUTOTEKA_CLIENT_ID", + "AVITO_AUTOTEKA_CLIENT_ID", + "AUTOTEKA_CLIENT_ID", + ), + "autoteka_client_secret": ( + "AVITO_AUTH__AUTOTEKA_CLIENT_SECRET", + "AVITO_AUTOTEKA_CLIENT_SECRET", + "AUTOTEKA_CLIENT_SECRET", + ), + "autoteka_scope": ( + "AVITO_AUTH__AUTOTEKA_SCOPE", + "AVITO_AUTOTEKA_SCOPE", + "AUTOTEKA_SCOPE", + ), + } + + model_config = ConfigDict( extra="ignore", populate_by_name=True, ) client_id: str | None = Field( default=None, - validation_alias=AliasChoices("CLIENT_ID", "AVITO_CLIENT_ID"), + validation_alias=AliasChoices("AVITO_AUTH__CLIENT_ID", "AVITO_CLIENT_ID", "CLIENT_ID"), ) client_secret: str | None = Field( default=None, validation_alias=AliasChoices( - "CLIENT_SECRET", "SECRET", "AVITO_CLIENT_SECRET", "AVITO_SECRET" + "AVITO_AUTH__CLIENT_SECRET", + "AVITO_CLIENT_SECRET", + "AVITO_SECRET", + "CLIENT_SECRET", + "SECRET", ), ) - scope: str | None = Field(default=None, validation_alias=AliasChoices("SCOPE", "AVITO_SCOPE")) + scope: str | None = Field( + default=None, + validation_alias=AliasChoices("AVITO_AUTH__SCOPE", "AVITO_SCOPE", "SCOPE"), + ) refresh_token: str | None = Field( default=None, - validation_alias=AliasChoices("REFRESH_TOKEN", "AVITO_REFRESH_TOKEN"), + validation_alias=AliasChoices( + "AVITO_AUTH__REFRESH_TOKEN", "AVITO_REFRESH_TOKEN", "REFRESH_TOKEN" + ), ) token_url: str = Field( default="/token", - validation_alias=AliasChoices("TOKEN_URL", "AVITO_TOKEN_URL"), + validation_alias=AliasChoices("AVITO_AUTH__TOKEN_URL", "AVITO_TOKEN_URL", "TOKEN_URL"), ) legacy_token_url: str = Field( default="/token", - validation_alias=AliasChoices("LEGACY_TOKEN_URL", "AVITO_LEGACY_TOKEN_URL"), + validation_alias=AliasChoices( + "AVITO_AUTH__LEGACY_TOKEN_URL", + "AVITO_LEGACY_TOKEN_URL", + "LEGACY_TOKEN_URL", + ), ) autoteka_token_url: str = Field( default="/autoteka/token", - validation_alias=AliasChoices("AUTOTEKA_TOKEN_URL", "AVITO_AUTOTEKA_TOKEN_URL"), + validation_alias=AliasChoices( + "AVITO_AUTH__AUTOTEKA_TOKEN_URL", + "AVITO_AUTOTEKA_TOKEN_URL", + "AUTOTEKA_TOKEN_URL", + ), ) autoteka_client_id: str | None = Field( default=None, - validation_alias=AliasChoices("AUTOTEKA_CLIENT_ID", "AVITO_AUTOTEKA_CLIENT_ID"), + validation_alias=AliasChoices( + "AVITO_AUTH__AUTOTEKA_CLIENT_ID", + "AVITO_AUTOTEKA_CLIENT_ID", + "AUTOTEKA_CLIENT_ID", + ), ) autoteka_client_secret: str | None = Field( default=None, - validation_alias=AliasChoices("AUTOTEKA_CLIENT_SECRET", "AVITO_AUTOTEKA_CLIENT_SECRET"), + validation_alias=AliasChoices( + "AVITO_AUTH__AUTOTEKA_CLIENT_SECRET", + "AVITO_AUTOTEKA_CLIENT_SECRET", + "AUTOTEKA_CLIENT_SECRET", + ), ) autoteka_scope: str | None = Field( default=None, - validation_alias=AliasChoices("AUTOTEKA_SCOPE", "AVITO_AUTOTEKA_SCOPE"), + validation_alias=AliasChoices( + "AVITO_AUTH__AUTOTEKA_SCOPE", + "AVITO_AUTOTEKA_SCOPE", + "AUTOTEKA_SCOPE", + ), ) + @classmethod + def from_env(cls, *, env_file: str | Path | None = ".env") -> AuthSettings: + """Загружает auth-настройки из процесса и optional `.env` файла.""" + + resolved_values = resolve_env_aliases(cls.ENV_ALIASES, env_file=env_file) + return cls(**resolved_values).validate_required() + + @classmethod + def supported_env_vars(cls) -> dict[str, tuple[str, ...]]: + """Возвращает документированный набор env-переменных и alias-имен.""" + + return dict(cls.ENV_ALIASES) + + def validate_required(self) -> AuthSettings: + """Проверяет обязательные поля OAuth-конфигурации.""" + + missing_fields: list[str] = [] + if not self.client_id: + missing_fields.append( + "client_id: " + + ", ".join(self.ENV_ALIASES["client_id"]) + ) + if not self.client_secret: + missing_fields.append( + "client_secret: " + + ", ".join(self.ENV_ALIASES["client_secret"]) + ) + if missing_fields: + raise ConfigurationError( + "Не заданы обязательные настройки OAuth. Ожидаются " + + "; ".join(missing_fields) + + "." + ) + return self + __all__ = ("AuthSettings",) diff --git a/avito/autoteka/client.py b/avito/autoteka/client.py index 9e9b891..b8875fd 100644 --- a/avito/autoteka/client.py +++ b/avito/autoteka/client.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass from avito.autoteka.mappers import ( @@ -23,14 +22,15 @@ AutotekaLeadsResult, AutotekaPackageInfo, AutotekaPreviewInfo, + AutotekaQuery, AutotekaReportInfo, AutotekaReportsResult, + AutotekaRequest, AutotekaScoringInfo, AutotekaSpecificationInfo, AutotekaTeaserInfo, AutotekaValuationInfo, CatalogResolveResult, - JsonRequest, MonitoringBucketResult, MonitoringEventsResult, ) @@ -60,7 +60,7 @@ def _context(self, operation_name: str, *, allow_retry: bool = False) -> Request class CatalogClient(AutotekaBaseClient): """Выполняет HTTP-операции автокаталога.""" - def get_catalogs_resolve(self, request: JsonRequest) -> CatalogResolveResult: + def get_catalogs_resolve(self, request: AutotekaRequest) -> CatalogResolveResult: payload = self.transport.request_json( "POST", "/autoteka/v1/catalogs/resolve", @@ -74,7 +74,7 @@ def get_catalogs_resolve(self, request: JsonRequest) -> CatalogResolveResult: class LeadsClient(AutotekaBaseClient): """Выполняет HTTP-операции сервиса Сигнал.""" - def get_leads(self, request: JsonRequest) -> AutotekaLeadsResult: + def get_leads(self, request: AutotekaRequest) -> AutotekaLeadsResult: payload = self.transport.request_json( "POST", "/autoteka/v1/get-leads/", @@ -88,26 +88,26 @@ def get_leads(self, request: JsonRequest) -> AutotekaLeadsResult: class PreviewClient(AutotekaBaseClient): """Выполняет HTTP-операции превью автомобиля.""" - def create_by_vin(self, request: JsonRequest) -> AutotekaPreviewInfo: + def create_by_vin(self, request: AutotekaRequest) -> AutotekaPreviewInfo: return self._post_preview( "/autoteka/v1/previews", "autoteka.preview.create_by_vin", request ) - def create_by_external_item(self, request: JsonRequest) -> AutotekaPreviewInfo: + def create_by_external_item(self, request: AutotekaRequest) -> AutotekaPreviewInfo: return self._post_preview( "/autoteka/v1/request-preview-by-external-item", "autoteka.preview.create_by_external_item", request, ) - def create_by_item_id(self, request: JsonRequest) -> AutotekaPreviewInfo: + def create_by_item_id(self, request: AutotekaRequest) -> AutotekaPreviewInfo: return self._post_preview( "/autoteka/v1/request-preview-by-item-id", "autoteka.preview.create_by_item_id", request, ) - def create_by_reg_number(self, request: JsonRequest) -> AutotekaPreviewInfo: + def create_by_reg_number(self, request: AutotekaRequest) -> AutotekaPreviewInfo: return self._post_preview( "/autoteka/v1/request-preview-by-regnumber", "autoteka.preview.create_by_reg_number", @@ -122,7 +122,7 @@ def get_preview(self, *, preview_id: int | str) -> AutotekaPreviewInfo: ) return map_preview(payload) - def _post_preview(self, path: str, operation: str, request: JsonRequest) -> AutotekaPreviewInfo: + def _post_preview(self, path: str, operation: str, request: AutotekaRequest) -> AutotekaPreviewInfo: payload = self.transport.request_json( "POST", path, @@ -144,10 +144,10 @@ def get_active_package(self) -> AutotekaPackageInfo: ) return map_package(payload) - def create_report(self, request: JsonRequest) -> AutotekaReportInfo: + def create_report(self, request: AutotekaRequest) -> AutotekaReportInfo: return self._post_report("/autoteka/v1/reports", "autoteka.report.create", request) - def create_report_by_vehicle_id(self, request: JsonRequest) -> AutotekaReportInfo: + def create_report_by_vehicle_id(self, request: AutotekaRequest) -> AutotekaReportInfo: return self._post_report( "/autoteka/v1/reports-by-vehicle-id", "autoteka.report.create_by_vehicle_id", @@ -170,21 +170,21 @@ def get_report(self, *, report_id: int | str) -> AutotekaReportInfo: ) return map_report(payload) - def create_sync_report_by_reg_number(self, request: JsonRequest) -> AutotekaReportInfo: + def create_sync_report_by_reg_number(self, request: AutotekaRequest) -> AutotekaReportInfo: return self._post_report( "/autoteka/v1/sync/create-by-regnumber", "autoteka.report.create_sync_by_reg_number", request, ) - def create_sync_report_by_vin(self, request: JsonRequest) -> AutotekaReportInfo: + def create_sync_report_by_vin(self, request: AutotekaRequest) -> AutotekaReportInfo: return self._post_report( "/autoteka/v1/sync/create-by-vin", "autoteka.report.create_sync_by_vin", request, ) - def _post_report(self, path: str, operation: str, request: JsonRequest) -> AutotekaReportInfo: + def _post_report(self, path: str, operation: str, request: AutotekaRequest) -> AutotekaReportInfo: payload = self.transport.request_json( "POST", path, @@ -198,7 +198,7 @@ def _post_report(self, path: str, operation: str, request: JsonRequest) -> Autot class MonitoringClient(AutotekaBaseClient): """Выполняет HTTP-операции мониторинга.""" - def add_bucket(self, request: JsonRequest) -> MonitoringBucketResult: + def add_bucket(self, request: AutotekaRequest) -> MonitoringBucketResult: return self._post_bucket( "/autoteka/v1/monitoring/bucket/add", "autoteka.monitoring.bucket_add", @@ -213,7 +213,7 @@ def delete_bucket(self) -> MonitoringBucketResult: ) return map_monitoring_bucket(payload) - def remove_bucket(self, request: JsonRequest) -> MonitoringBucketResult: + def remove_bucket(self, request: AutotekaRequest) -> MonitoringBucketResult: return self._post_bucket( "/autoteka/v1/monitoring/bucket/remove", "autoteka.monitoring.bucket_remove", @@ -221,18 +221,18 @@ def remove_bucket(self, request: JsonRequest) -> MonitoringBucketResult: ) def get_reg_actions( - self, *, params: Mapping[str, object] | None = None + self, *, query: AutotekaQuery | None = None ) -> MonitoringEventsResult: payload = self.transport.request_json( "GET", "/autoteka/v1/monitoring/get-reg-actions/", context=self._context("autoteka.monitoring.get_reg_actions"), - params=params, + params=query.to_params() if query is not None else None, ) return map_monitoring_events(payload) def _post_bucket( - self, path: str, operation: str, request: JsonRequest + self, path: str, operation: str, request: AutotekaRequest ) -> MonitoringBucketResult: payload = self.transport.request_json( "POST", @@ -247,7 +247,7 @@ def _post_bucket( class ScoringClient(AutotekaBaseClient): """Выполняет HTTP-операции скоринга рисков.""" - def create_by_vehicle_id(self, request: JsonRequest) -> AutotekaScoringInfo: + def create_by_vehicle_id(self, request: AutotekaRequest) -> AutotekaScoringInfo: payload = self.transport.request_json( "POST", "/autoteka/v1/scoring/by-vehicle-id", @@ -269,14 +269,14 @@ def get_by_id(self, *, scoring_id: int | str) -> AutotekaScoringInfo: class SpecificationsClient(AutotekaBaseClient): """Выполняет HTTP-операции спецификаций автомобиля.""" - def create_by_plate_number(self, request: JsonRequest) -> AutotekaSpecificationInfo: + def create_by_plate_number(self, request: AutotekaRequest) -> AutotekaSpecificationInfo: return self._post_specification( "/autoteka/v1/specifications/by-plate-number", "autoteka.specification.create_by_plate_number", request, ) - def create_by_vehicle_id(self, request: JsonRequest) -> AutotekaSpecificationInfo: + def create_by_vehicle_id(self, request: AutotekaRequest) -> AutotekaSpecificationInfo: return self._post_specification( "/autoteka/v1/specifications/by-vehicle-id", "autoteka.specification.create_by_vehicle_id", @@ -295,7 +295,7 @@ def _post_specification( self, path: str, operation: str, - request: JsonRequest, + request: AutotekaRequest, ) -> AutotekaSpecificationInfo: payload = self.transport.request_json( "POST", @@ -310,7 +310,7 @@ def _post_specification( class TeaserClient(AutotekaBaseClient): """Выполняет HTTP-операции тизеров.""" - def create(self, request: JsonRequest) -> AutotekaTeaserInfo: + def create(self, request: AutotekaRequest) -> AutotekaTeaserInfo: payload = self.transport.request_json( "POST", "/autoteka/v1/teasers", @@ -332,7 +332,7 @@ def get(self, *, teaser_id: int | str) -> AutotekaTeaserInfo: class ValuationClient(AutotekaBaseClient): """Выполняет HTTP-операции оценки стоимости.""" - def get_by_specification(self, request: JsonRequest) -> AutotekaValuationInfo: + def get_by_specification(self, request: AutotekaRequest) -> AutotekaValuationInfo: payload = self.transport.request_json( "POST", "/autoteka/v1/valuation/by-specification", diff --git a/avito/autoteka/domain.py b/avito/autoteka/domain.py index 2af7b36..c3178bf 100644 --- a/avito/autoteka/domain.py +++ b/avito/autoteka/domain.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass from avito.autoteka.client import ( @@ -20,18 +19,19 @@ AutotekaLeadsResult, AutotekaPackageInfo, AutotekaPreviewInfo, + AutotekaQuery, AutotekaReportInfo, AutotekaReportsResult, + AutotekaRequest, AutotekaScoringInfo, AutotekaSpecificationInfo, AutotekaTeaserInfo, AutotekaValuationInfo, CatalogResolveResult, - JsonRequest, MonitoringBucketResult, MonitoringEventsResult, ) -from avito.core import Transport +from avito.core import Transport, ValidationError @dataclass(slots=True, frozen=True) @@ -48,14 +48,14 @@ class AutotekaVehicle(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def get_catalogs_resolve(self, *, payload: Mapping[str, object]) -> CatalogResolveResult: - return CatalogClient(self.transport).get_catalogs_resolve(JsonRequest(payload)) + def get_catalogs_resolve(self, *, request: AutotekaRequest) -> CatalogResolveResult: + return CatalogClient(self.transport).get_catalogs_resolve(request) - def get_leads(self, *, payload: Mapping[str, object]) -> AutotekaLeadsResult: - return LeadsClient(self.transport).get_leads(JsonRequest(payload)) + def get_leads(self, *, request: AutotekaRequest) -> AutotekaLeadsResult: + return LeadsClient(self.transport).get_leads(request) - def create_preview_by_vin(self, *, payload: Mapping[str, object]) -> AutotekaPreviewInfo: - return PreviewClient(self.transport).create_by_vin(JsonRequest(payload)) + def create_preview_by_vin(self, *, request: AutotekaRequest) -> AutotekaPreviewInfo: + return PreviewClient(self.transport).create_by_vin(request) def get_preview(self, *, preview_id: int | str | None = None) -> AutotekaPreviewInfo: return PreviewClient(self.transport).get_preview( @@ -63,27 +63,27 @@ def get_preview(self, *, preview_id: int | str | None = None) -> AutotekaPreview ) def create_preview_by_external_item( - self, *, payload: Mapping[str, object] + self, *, request: AutotekaRequest ) -> AutotekaPreviewInfo: - return PreviewClient(self.transport).create_by_external_item(JsonRequest(payload)) + return PreviewClient(self.transport).create_by_external_item(request) - def create_preview_by_item_id(self, *, payload: Mapping[str, object]) -> AutotekaPreviewInfo: - return PreviewClient(self.transport).create_by_item_id(JsonRequest(payload)) + def create_preview_by_item_id(self, *, request: AutotekaRequest) -> AutotekaPreviewInfo: + return PreviewClient(self.transport).create_by_item_id(request) - def create_preview_by_reg_number(self, *, payload: Mapping[str, object]) -> AutotekaPreviewInfo: - return PreviewClient(self.transport).create_by_reg_number(JsonRequest(payload)) + def create_preview_by_reg_number(self, *, request: AutotekaRequest) -> AutotekaPreviewInfo: + return PreviewClient(self.transport).create_by_reg_number(request) def create_specification_by_plate_number( - self, *, payload: Mapping[str, object] + self, *, request: AutotekaRequest ) -> AutotekaSpecificationInfo: - return SpecificationsClient(self.transport).create_by_plate_number(JsonRequest(payload)) + return SpecificationsClient(self.transport).create_by_plate_number(request) def create_specification_by_vehicle_id( - self, *, payload: Mapping[str, object] + self, *, request: AutotekaRequest ) -> AutotekaSpecificationInfo: - return SpecificationsClient(self.transport).create_by_vehicle_id(JsonRequest(payload)) + return SpecificationsClient(self.transport).create_by_vehicle_id(request) - def get_specification_get_by_id( + def get_specification_by_id( self, *, specification_id: int | str | None = None, @@ -92,8 +92,8 @@ def get_specification_get_by_id( specification_id=specification_id or self._require_resource_id("specification_id") ) - def create_teaser(self, *, payload: Mapping[str, object]) -> AutotekaTeaserInfo: - return TeaserClient(self.transport).create(JsonRequest(payload)) + def create_teaser(self, *, request: AutotekaRequest) -> AutotekaTeaserInfo: + return TeaserClient(self.transport).create(request) def get_teaser(self, *, teaser_id: int | str | None = None) -> AutotekaTeaserInfo: return TeaserClient(self.transport).get( @@ -102,7 +102,7 @@ def get_teaser(self, *, teaser_id: int | str | None = None) -> AutotekaTeaserInf def _require_resource_id(self, field_name: str) -> str: if self.resource_id is None: - raise ValueError(f"Для операции требуется `{field_name}`.") + raise ValidationError(f"Для операции требуется `{field_name}`.") return str(self.resource_id) @@ -116,11 +116,11 @@ class AutotekaReport(DomainObject): def get_active_package(self) -> AutotekaPackageInfo: return ReportClient(self.transport).get_active_package() - def create_report(self, *, payload: Mapping[str, object]) -> AutotekaReportInfo: - return ReportClient(self.transport).create_report(JsonRequest(payload)) + def create_report(self, *, request: AutotekaRequest) -> AutotekaReportInfo: + return ReportClient(self.transport).create_report(request) - def create_report_by_vehicle_id(self, *, payload: Mapping[str, object]) -> AutotekaReportInfo: - return ReportClient(self.transport).create_report_by_vehicle_id(JsonRequest(payload)) + def create_report_by_vehicle_id(self, *, request: AutotekaRequest) -> AutotekaReportInfo: + return ReportClient(self.transport).create_report_by_vehicle_id(request) def list_report_list(self) -> AutotekaReportsResult: return ReportClient(self.transport).list_reports() @@ -130,19 +130,19 @@ def get_report(self, *, report_id: int | str | None = None) -> AutotekaReportInf report_id=report_id or self._require_resource_id() ) - def create_sync_create_report_by_reg_number( - self, *, payload: Mapping[str, object] + def create_sync_report_by_reg_number( + self, *, request: AutotekaRequest ) -> AutotekaReportInfo: - return ReportClient(self.transport).create_sync_report_by_reg_number(JsonRequest(payload)) + return ReportClient(self.transport).create_sync_report_by_reg_number(request) - def create_sync_create_report_by_vin( - self, *, payload: Mapping[str, object] + def create_sync_report_by_vin( + self, *, request: AutotekaRequest ) -> AutotekaReportInfo: - return ReportClient(self.transport).create_sync_report_by_vin(JsonRequest(payload)) + return ReportClient(self.transport).create_sync_report_by_vin(request) def _require_resource_id(self) -> str: if self.resource_id is None: - raise ValueError("Для операции требуется `report_id`.") + raise ValidationError("Для операции требуется `report_id`.") return str(self.resource_id) @@ -154,24 +154,24 @@ class AutotekaMonitoring(DomainObject): user_id: int | str | None = None def create_monitoring_bucket_add( - self, *, payload: Mapping[str, object] + self, *, request: AutotekaRequest ) -> MonitoringBucketResult: - return MonitoringClient(self.transport).add_bucket(JsonRequest(payload)) + return MonitoringClient(self.transport).add_bucket(request) def list_monitoring_bucket_delete(self) -> MonitoringBucketResult: return MonitoringClient(self.transport).delete_bucket() def delete_monitoring_bucket_remove( - self, *, payload: Mapping[str, object] + self, *, request: AutotekaRequest ) -> MonitoringBucketResult: - return MonitoringClient(self.transport).remove_bucket(JsonRequest(payload)) + return MonitoringClient(self.transport).remove_bucket(request) - def get_monitoring_get_reg_actions( + def get_monitoring_reg_actions( self, *, - params: Mapping[str, object] | None = None, + query: AutotekaQuery | None = None, ) -> MonitoringEventsResult: - return MonitoringClient(self.transport).get_reg_actions(params=params) + return MonitoringClient(self.transport).get_reg_actions(query=query) @dataclass(slots=True, frozen=True) @@ -181,17 +181,17 @@ class AutotekaScoring(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def create_scoring_by_vehicle_id(self, *, payload: Mapping[str, object]) -> AutotekaScoringInfo: - return ScoringClient(self.transport).create_by_vehicle_id(JsonRequest(payload)) + def create_scoring_by_vehicle_id(self, *, request: AutotekaRequest) -> AutotekaScoringInfo: + return ScoringClient(self.transport).create_by_vehicle_id(request) - def get_scoring_get_by_id(self, *, scoring_id: int | str | None = None) -> AutotekaScoringInfo: + def get_scoring_by_id(self, *, scoring_id: int | str | None = None) -> AutotekaScoringInfo: return ScoringClient(self.transport).get_by_id( scoring_id=scoring_id or self._require_resource_id() ) def _require_resource_id(self) -> str: if self.resource_id is None: - raise ValueError("Для операции требуется `scoring_id`.") + raise ValidationError("Для операции требуется `scoring_id`.") return str(self.resource_id) @@ -203,9 +203,9 @@ class AutotekaValuation(DomainObject): user_id: int | str | None = None def get_valuation_by_specification( - self, *, payload: Mapping[str, object] + self, *, request: AutotekaRequest ) -> AutotekaValuationInfo: - return ValuationClient(self.transport).get_by_specification(JsonRequest(payload)) + return ValuationClient(self.transport).get_by_specification(request) __all__ = ( diff --git a/avito/autoteka/mappers.py b/avito/autoteka/mappers.py index 45c3ed8..44a25c9 100644 --- a/avito/autoteka/mappers.py +++ b/avito/autoteka/mappers.py @@ -94,15 +94,15 @@ def map_catalogs_resolve(payload: object) -> CatalogResolveResult: CatalogFieldValue( value_id=_str(value, "valueId", "id"), label=_str(value, "label", "value"), - raw_payload=value, + _payload=value, ) for value in _list(item, "values", "items") ], - raw_payload=item, + _payload=item, ) for item in _list(result, "fields", "items") ], - raw_payload=data, + _payload=data, ) @@ -125,10 +125,10 @@ def map_leads(payload: object) -> AutotekaLeadsResult: price=_int(event_payload, "price"), created_at=_str(event_payload, "itemCreatedAt"), url=_str(event_payload, "url"), - raw_payload=item, + _payload=item, ) ) - return AutotekaLeadsResult(items=items, last_id=_int(pagination, "lastId"), raw_payload=data) + return AutotekaLeadsResult(items=items, last_id=_int(pagination, "lastId"), _payload=data) def map_monitoring_bucket(payload: object) -> MonitoringBucketResult: @@ -142,11 +142,11 @@ def map_monitoring_bucket(payload: object) -> MonitoringBucketResult: MonitoringInvalidVehicle( vehicle_id=_str(item, "vehicleID", "vehicleId"), description=_str(item, "description"), - raw_payload=item, + _payload=item, ) for item in _list(result, "invalidVehicles", "items") ], - raw_payload=data, + _payload=data, ) @@ -167,14 +167,14 @@ def map_monitoring_events(payload: object) -> MonitoringEventsResult: operation_date_to=_str(item, "operationDateTo"), owner_code=_int(item, "ownerCode"), actual_at=_int(item, "actualAt"), - raw_payload=item, + _payload=item, ) for item in _list(data, "data", "items") ], has_next=_bool(pagination, "hasNext"), next_cursor=_str(pagination, "nextCursor"), next_link=_str(pagination, "nextLink"), - raw_payload=data, + _payload=data, ) @@ -189,7 +189,7 @@ def map_package(payload: object) -> AutotekaPackageInfo: reports_remaining=_int(package, "reportsCntRemain"), created_at=_str(package, "createdTime"), expires_at=_str(package, "expireTime"), - raw_payload=data, + _payload=data, ) @@ -199,7 +199,7 @@ def _map_preview_source(source: Payload) -> AutotekaPreviewInfo: status=_str(source, "status"), vehicle_id=_str(source, "vin", "vehicleId"), reg_number=_str(source, "regNumber", "plateNumber"), - raw_payload=source, + _payload=source, ) @@ -222,7 +222,7 @@ def _map_report_source(source: Payload) -> AutotekaReportInfo: created_at=_str(source, "createdAt") or _str(data, "createdAt"), web_link=_str(source, "webLink"), pdf_link=_str(source, "pdfLink"), - raw_payload=source, + _payload=source, ) @@ -242,7 +242,7 @@ def map_reports(payload: object) -> AutotekaReportsResult: data = _expect_mapping(payload) return AutotekaReportsResult( items=[_map_report_source(item) for item in _list(data, "result", "items")], - raw_payload=data, + _payload=data, ) @@ -257,7 +257,7 @@ def map_scoring(payload: object) -> AutotekaScoringInfo: scoring_id=_str(source, "scoringId"), is_completed=_bool(source, "isCompleted"), created_at=_int(source, "createdAt"), - raw_payload=source, + _payload=source, ) @@ -273,7 +273,7 @@ def map_specification(payload: object) -> AutotekaSpecificationInfo: status=_str(source, "status"), vehicle_id=_str(source, "vehicleId"), plate_number=_str(source, "plateNumber"), - raw_payload=source, + _payload=source, ) @@ -291,7 +291,7 @@ def map_teaser(payload: object) -> AutotekaTeaserInfo: brand=_str(teaser_data, "brand") if teaser_data else _str(source, "brand"), model=_str(teaser_data, "model") if teaser_data else _str(source, "model"), year=_int(teaser_data, "year") if teaser_data else _int(source, "year"), - raw_payload=data, + _payload=data, ) @@ -312,5 +312,5 @@ def map_valuation(payload: object) -> AutotekaValuationInfo: mileage=_int(source, "mileage"), avg_price_with_condition=_int(valuation, "avgPriceWithCondition"), avg_market_price=_int(valuation, "avgMarketPrice"), - raw_payload=data, + _payload=data, ) diff --git a/avito/autoteka/models.py b/avito/autoteka/models.py index eaab155..dee52a4 100644 --- a/avito/autoteka/models.py +++ b/avito/autoteka/models.py @@ -5,10 +5,12 @@ from collections.abc import Mapping from dataclasses import dataclass, field +from avito.core.serialization import enable_module_serialization + @dataclass(slots=True, frozen=True) -class JsonRequest: - """Типизированная обертка над JSON payload запроса.""" +class AutotekaRequest: + """Унифицированный typed request для Автотеки.""" payload: Mapping[str, object] @@ -18,13 +20,25 @@ def to_payload(self) -> dict[str, object]: return dict(self.payload) +@dataclass(slots=True, frozen=True) +class AutotekaQuery: + """Унифицированный typed query для Автотеки.""" + + params: Mapping[str, object] + + def to_params(self) -> dict[str, object]: + """Сериализует query-параметры запроса.""" + + return dict(self.params) + + @dataclass(slots=True, frozen=True) class CatalogFieldValue: """Значение параметра автокаталога.""" value_id: str | None label: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -35,7 +49,7 @@ class CatalogField: label: str | None data_type: str | None values: list[CatalogFieldValue] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -43,7 +57,7 @@ class CatalogResolveResult: """Результат актуализации параметров автокаталога.""" items: list[CatalogField] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -59,7 +73,7 @@ class AutotekaLeadEvent: price: int | None created_at: str | None url: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -68,7 +82,7 @@ class AutotekaLeadsResult: items: list[AutotekaLeadEvent] last_id: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -77,7 +91,7 @@ class MonitoringInvalidVehicle: vehicle_id: str | None description: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -86,7 +100,7 @@ class MonitoringBucketResult: success: bool invalid_vehicles: list[MonitoringInvalidVehicle] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -102,7 +116,7 @@ class MonitoringEvent: operation_date_to: str | None owner_code: int | None actual_at: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -113,7 +127,7 @@ class MonitoringEventsResult: has_next: bool | None = None next_cursor: str | None = None next_link: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -124,7 +138,7 @@ class AutotekaPackageInfo: reports_remaining: int | None created_at: str | None expires_at: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -135,7 +149,7 @@ class AutotekaPreviewInfo: status: str | None vehicle_id: str | None reg_number: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -148,7 +162,7 @@ class AutotekaReportInfo: created_at: str | None web_link: str | None pdf_link: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -156,7 +170,7 @@ class AutotekaReportsResult: """Список отчетов Автотеки.""" items: list[AutotekaReportInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -166,7 +180,7 @@ class AutotekaScoringInfo: scoring_id: str | None is_completed: bool | None created_at: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -177,7 +191,7 @@ class AutotekaSpecificationInfo: status: str | None vehicle_id: str | None plate_number: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -189,7 +203,7 @@ class AutotekaTeaserInfo: brand: str | None = None model: str | None = None year: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -205,4 +219,7 @@ class AutotekaValuationInfo: mileage: int | None avg_price_with_condition: int | None avg_market_price: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) + + +enable_module_serialization(globals()) diff --git a/avito/client/client.py b/avito/client/client.py index cdee040..97746bb 100644 --- a/avito/client/client.py +++ b/avito/client/client.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pathlib import Path + import httpx from avito.accounts import Account, AccountHierarchy @@ -47,10 +49,16 @@ class AvitoClient: """ def __init__(self, settings: AvitoSettings | None = None) -> None: - self.settings = settings or AvitoSettings.from_env() + self.settings = (settings or AvitoSettings.from_env()).validate_required() self.auth_provider = self._build_auth_provider() self.transport = Transport(self.settings, auth_provider=self.auth_provider) + @classmethod + def from_env(cls, *, env_file: str | Path | None = ".env") -> AvitoClient: + """Создает клиент из переменных окружения и optional `.env` файла.""" + + return cls(AvitoSettings.from_env(env_file=env_file)) + def auth(self) -> AuthProvider: """Возвращает объект аутентификации и token-flow операций.""" diff --git a/avito/config.py b/avito/config.py index c0203d8..dc30379 100644 --- a/avito/config.py +++ b/avito/config.py @@ -2,47 +2,95 @@ from __future__ import annotations -from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict +from pathlib import Path +from typing import ClassVar +from pydantic import AliasChoices, BaseModel, ConfigDict, Field + +from avito._env import resolve_env_aliases from avito.auth.settings import AuthSettings from avito.core.retries import RetryPolicy from avito.core.types import ApiTimeouts -class AvitoSettings(BaseSettings): - """Корневая конфигурация SDK с настройками transport и авторизации.""" +def _default_timeouts() -> ApiTimeouts: + return ApiTimeouts(_env_file=None) # type: ignore[call-arg] + + +def _default_retry_policy() -> RetryPolicy: + return RetryPolicy(_env_file=None) # type: ignore[call-arg] + - model_config = SettingsConfigDict( - env_prefix="AVITO_", - env_file=".env", - env_nested_delimiter="__", +class AvitoSettings(BaseModel): + """Единственный публичный контракт конфигурации SDK.""" + + ENV_ALIASES: ClassVar[dict[str, tuple[str, ...]]] = { + "base_url": ("AVITO_BASE_URL", "BASE_URL"), + "user_id": ("AVITO_USER_ID", "USER_ID"), + } + + model_config = ConfigDict( extra="ignore", + populate_by_name=True, ) - base_url: str = "https://api.avito.ru" - user_id: int | None = None + base_url: str = Field( + default="https://api.avito.ru", + validation_alias=AliasChoices("BASE_URL", "AVITO_BASE_URL"), + ) + user_id: int | None = Field( + default=None, + validation_alias=AliasChoices("USER_ID", "AVITO_USER_ID"), + ) auth: AuthSettings = Field(default_factory=AuthSettings) - timeouts: ApiTimeouts = Field(default_factory=ApiTimeouts) - retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) + timeouts: ApiTimeouts = Field(default_factory=_default_timeouts) + retry_policy: RetryPolicy = Field(default_factory=_default_retry_policy) @property def client_id(self) -> str | None: - """Возвращает client id для совместимости с ранними версиями SDK.""" + """Возвращает `client_id` для совместимости со старым API.""" return self.auth.client_id @property def client_secret(self) -> str | None: - """Возвращает client secret для совместимости с ранними версиями SDK.""" + """Возвращает `client_secret` для совместимости со старым API.""" return self.auth.client_secret @classmethod - def from_env(cls) -> AvitoSettings: - """Загружает конфигурацию SDK из переменных окружения.""" + def from_env(cls, *, env_file: str | Path | None = ".env") -> AvitoSettings: + """Загружает конфигурацию из окружения и optional `.env` файла.""" + + resolved_values = resolve_env_aliases(cls.ENV_ALIASES, env_file=env_file) + auth_settings = AuthSettings.from_env(env_file=env_file) + return cls.model_validate( + { + **resolved_values, + "auth": auth_settings, + "timeouts": ApiTimeouts(_env_file=env_file), # type: ignore[call-arg] + "retry_policy": RetryPolicy(_env_file=env_file), # type: ignore[call-arg] + } + ).validate_required() + + @classmethod + def supported_env_vars(cls) -> dict[str, tuple[str, ...]]: + """Возвращает документированный набор env-переменных SDK.""" + + env_vars = dict(cls.ENV_ALIASES) + env_vars.update( + { + f"auth.{field_name}": aliases + for field_name, aliases in AuthSettings.supported_env_vars().items() + } + ) + return env_vars + + def validate_required(self) -> AvitoSettings: + """Проверяет обязательные части публичной конфигурации SDK.""" - return cls() + self.auth.validate_required() + return self __all__ = ("AvitoSettings",) diff --git a/avito/core/__init__.py b/avito/core/__init__.py index 203f496..6fb173e 100644 --- a/avito/core/__init__.py +++ b/avito/core/__init__.py @@ -2,18 +2,24 @@ from avito.core.exceptions import ( AuthenticationError, + AuthorizationError, AvitoError, ClientError, + ConfigurationError, + ConflictError, NotFoundError, PermissionDeniedError, RateLimitError, ResponseMappingError, ServerError, TransportError, + UnsupportedOperationError, + UpstreamApiError, ValidationError, ) from avito.core.pagination import PaginatedList, Paginator from avito.core.retries import RetryDecision, RetryPolicy +from avito.core.serialization import SerializableModel from avito.core.transport import Transport from avito.core.types import ( ApiTimeouts, @@ -26,9 +32,12 @@ __all__ = ( "ApiTimeouts", "AuthenticationError", + "AuthorizationError", "AvitoError", "BinaryResponse", "ClientError", + "ConfigurationError", + "ConflictError", "JsonPage", "NotFoundError", "PaginatedList", @@ -39,9 +48,12 @@ "ResponseMappingError", "RetryDecision", "RetryPolicy", + "SerializableModel", "ServerError", "Transport", "TransportDebugInfo", "TransportError", + "UnsupportedOperationError", + "UpstreamApiError", "ValidationError", ) diff --git a/avito/core/exceptions.py b/avito/core/exceptions.py index 19e2e99..91cd01d 100644 --- a/avito/core/exceptions.py +++ b/avito/core/exceptions.py @@ -3,21 +3,68 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Any + +_SECRET_KEYS = ( + "authorization", + "access_token", + "refresh_token", + "token", + "client_secret", + "secret", + "password", +) + + +def _is_secret_key(key: object) -> bool: + return isinstance(key, str) and any(secret in key.lower() for secret in _SECRET_KEYS) + + +def sanitize_metadata(value: object) -> object: + """Удаляет секреты из диагностических метаданных исключения.""" + + if isinstance(value, Mapping): + sanitized: dict[str, object] = {} + for key, item in value.items(): + if _is_secret_key(key): + sanitized[str(key)] = "***" + else: + sanitized[str(key)] = sanitize_metadata(item) + return sanitized + if isinstance(value, list): + return [sanitize_metadata(item) for item in value] + if isinstance(value, tuple): + return tuple(sanitize_metadata(item) for item in value) + if isinstance(value, str) and any(secret in value.lower() for secret in _SECRET_KEYS): + return "***" + return value @dataclass(slots=True) class AvitoError(Exception): - """Базовое исключение SDK с метаданными HTTP-ответа.""" + """Базовое исключение SDK с безопасными диагностическими метаданными.""" message: str status_code: int | None = None error_code: str | None = None + operation: str | None = None + metadata: Mapping[str, Any] = field(default_factory=dict) payload: object | None = None headers: Mapping[str, str] | None = None + def __post_init__(self) -> None: + sanitized_payload = sanitize_metadata(self.payload) + sanitized_headers = sanitize_metadata(dict(self.headers)) if self.headers is not None else None + sanitized_metadata = sanitize_metadata(dict(self.metadata)) + object.__setattr__(self, "payload", sanitized_payload) + object.__setattr__(self, "headers", sanitized_headers) + object.__setattr__(self, "metadata", sanitized_metadata) + def __str__(self) -> str: details: list[str] = [self.message] + if self.operation is not None: + details.append(f"operation={self.operation}") if self.status_code is not None: details.append(f"status={self.status_code}") if self.error_code is not None: @@ -29,32 +76,52 @@ class TransportError(AvitoError): """Сбой HTTP-транспорта до получения корректного ответа API.""" -class AuthenticationError(AvitoError): - """Ошибка аутентификации или получения access token.""" +class AuthorizationError(AvitoError): + """Ошибка авторизации или недостатка прав API.""" -class PermissionDeniedError(AvitoError): - """Недостаточно прав для выполнения операции.""" +class AuthenticationError(AuthorizationError): + """Совместимое имя ошибки аутентификации.""" -class NotFoundError(AvitoError): - """Запрошенный ресурс не найден.""" +class PermissionDeniedError(AuthorizationError): + """Совместимое имя ошибки недостатка прав.""" class ValidationError(AvitoError): """API отклонил запрос из-за некорректных параметров.""" +class ConfigurationError(ValidationError): + """SDK сконфигурирован некорректно до выполнения HTTP-запроса.""" + + class RateLimitError(AvitoError): """Превышен лимит запросов API.""" -class ClientError(AvitoError): - """Прочая клиентская ошибка диапазона `4xx`.""" +class ConflictError(AvitoError): + """Операция конфликтует с текущим состоянием upstream-ресурса.""" -class ServerError(AvitoError): - """Серверная ошибка диапазона `5xx`.""" +class UnsupportedOperationError(AvitoError): + """Операция не поддерживается публичным Avito API или данным endpoint.""" + + +class UpstreamApiError(AvitoError): + """Неизвестная ошибка upstream API вне специализированных типов SDK.""" + + +class ClientError(UpstreamApiError): + """Совместимое имя прочей клиентской ошибки диапазона `4xx`.""" + + +class ServerError(UpstreamApiError): + """Совместимое имя серверной ошибки диапазона `5xx`.""" + + +class NotFoundError(UpstreamApiError): + """Запрошенный ресурс не найден.""" class ResponseMappingError(AvitoError): @@ -63,13 +130,19 @@ class ResponseMappingError(AvitoError): __all__ = ( "AuthenticationError", + "AuthorizationError", "AvitoError", "ClientError", + "ConfigurationError", + "ConflictError", "NotFoundError", "PermissionDeniedError", "RateLimitError", "ResponseMappingError", "ServerError", "TransportError", + "UnsupportedOperationError", + "UpstreamApiError", "ValidationError", + "sanitize_metadata", ) diff --git a/avito/core/mapping.py b/avito/core/mapping.py new file mode 100644 index 0000000..317f6e9 --- /dev/null +++ b/avito/core/mapping.py @@ -0,0 +1,36 @@ +"""Внутренние helper-ы для преобразования transport payload в публичные SDK-модели.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import TypeVar + +from avito.core.transport import Transport +from avito.core.types import HttpMethod, RequestContext + +ModelT = TypeVar("ModelT") + + +def request_public_model[ModelT]( + transport: Transport, + method: HttpMethod, + path: str, + *, + context: RequestContext, + mapper: Callable[[object], ModelT], + params: Mapping[str, object] | None = None, + json_body: Mapping[str, object] | None = None, +) -> ModelT: + """Выполняет HTTP-запрос и маппит JSON в публичную модель SDK.""" + + payload = transport.request_json( + method, + path, + context=context, + params=params, + json_body=json_body, + ) + return mapper(payload) + + +__all__ = ("request_public_model",) diff --git a/avito/core/pagination.py b/avito/core/pagination.py index 235f176..0562e9c 100644 --- a/avito/core/pagination.py +++ b/avito/core/pagination.py @@ -11,7 +11,14 @@ class PaginatedList[ItemT](list[ItemT]): - """Ленивый list-like контейнер для элементов из последовательности страниц.""" + """Ленивый list-like контейнер для элементов из последовательности страниц. + + Контракт: + + - уже загруженные элементы читаются без повторных запросов; + - чтение по индексу, slice и частичная итерация подгружают только нужные страницы; + - `materialize()` выполняет явную полную загрузку всех оставшихся страниц. + """ def __init__( self, @@ -79,6 +86,24 @@ def __eq__(self, other: object) -> bool: self._ensure_all_loaded() return super().__eq__(other) + @property + def loaded_count(self) -> int: + """Количество элементов, уже загруженных локально.""" + + return super().__len__() + + @property + def is_materialized(self) -> bool: + """Показывает, загружены ли все страницы коллекции.""" + + return self._exhausted + + def materialize(self) -> list[ItemT]: + """Явно загружает все страницы и возвращает snapshot-список.""" + + self._ensure_all_loaded() + return list(super().__iter__()) + def _ensure_slice_loaded(self, slice_index: slice) -> None: if ( slice_index.step is not None diff --git a/avito/core/serialization.py b/avito/core/serialization.py new file mode 100644 index 0000000..9f21f90 --- /dev/null +++ b/avito/core/serialization.py @@ -0,0 +1,66 @@ +"""Публичная сериализация SDK-моделей без transport-деталей.""" + +from __future__ import annotations + +from base64 import b64encode +from collections.abc import Mapping, Sequence +from dataclasses import fields, is_dataclass +from inspect import isclass +from typing import Any, cast + + +def _is_public_field(name: str) -> bool: + return not name.startswith("_") and name != "raw_payload" + + +def _serialize_value(value: object) -> object: + if isinstance(value, SerializableModel): + return value.to_dict() + if isinstance(value, bytes | bytearray): + return b64encode(bytes(value)).decode("ascii") + if is_dataclass(value): + return { + field.name: _serialize_value(getattr(value, field.name)) + for field in fields(value) + if _is_public_field(field.name) + } + if isinstance(value, Mapping): + return {str(key): _serialize_value(item) for key, item in value.items()} + if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): + return [_serialize_value(item) for item in value] + return value + + +class SerializableModel: + """Mixin для стабильной JSON-compatible сериализации публичных моделей.""" + + def to_dict(self) -> dict[str, Any]: + if not is_dataclass(self): + raise TypeError("SerializableModel supports dataclass instances only.") + return { + field.name: _serialize_value(getattr(self, field.name)) + for field in fields(self) + if _is_public_field(field.name) + } + + def model_dump(self) -> dict[str, Any]: + """Совместимый alias для pydantic-подобного публичного контракта.""" + + return self.to_dict() + + +def enable_module_serialization(namespace: Mapping[str, object]) -> None: + """Добавляет `to_dict()` / `model_dump()` всем dataclass-моделям модуля.""" + + module_name = namespace.get("__name__") + for value in namespace.values(): + if not isclass(value) or getattr(value, "__module__", None) != module_name: + continue + if not is_dataclass(value) or hasattr(value, "to_dict"): + continue + dynamic_value = cast(Any, value) + dynamic_value.to_dict = SerializableModel.to_dict + dynamic_value.model_dump = SerializableModel.model_dump + + +__all__ = ("SerializableModel", "enable_module_serialization") diff --git a/avito/core/transport.py b/avito/core/transport.py index 284d302..bd8c39e 100644 --- a/avito/core/transport.py +++ b/avito/core/transport.py @@ -15,13 +15,15 @@ from avito.auth.provider import AuthProvider from avito.core.exceptions import ( AuthenticationError, - ClientError, + AuthorizationError, + ConflictError, NotFoundError, - PermissionDeniedError, RateLimitError, ResponseMappingError, ServerError, TransportError, + UnsupportedOperationError, + UpstreamApiError, ValidationError, ) from avito.core.retries import RetryDecision @@ -75,6 +77,7 @@ def debug_info(self) -> TransportDebugInfo: 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, @@ -134,7 +137,11 @@ def request( if decision.should_retry: self._sleep(decision.delay_seconds) continue - raise TransportError(str(exc)) from exc + raise TransportError( + str(exc), + operation=context.operation_name, + metadata={"timeout": isinstance(exc, httpx.TimeoutException)}, + ) from exc if ( response.status_code == 401 @@ -142,7 +149,7 @@ def request( and self._auth_provider is not None ): if unauthorized_refresh_used: - raise self._map_http_error(response) + raise self._map_http_error(response, operation=context.operation_name) unauthorized_refresh_used = True self._auth_provider.invalidate_token() refreshed_headers = dict(request_headers) @@ -162,7 +169,7 @@ def request( if decision.should_retry: self._sleep(decision.delay_seconds) continue - raise self._map_http_error(response) + raise self._map_http_error(response, operation=context.operation_name) if 500 <= response.status_code < 600: decision = self._decide_http_retry( @@ -174,10 +181,10 @@ def request( if decision.should_retry: self._sleep(decision.delay_seconds) continue - raise self._map_http_error(response) + raise self._map_http_error(response, operation=context.operation_name) if response.is_error: - raise self._map_http_error(response) + raise self._map_http_error(response, operation=context.operation_name) return response @@ -211,6 +218,8 @@ def request_json( 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 @@ -346,44 +355,102 @@ def _decide_http_retry( ) return RetryDecision(False) - def _map_http_error(self, response: httpx.Response) -> Exception: + def _map_http_error(self, response: httpx.Response, *, operation: str | None = None) -> Exception: payload = self._safe_payload(response) message = self._extract_message(payload) or f"HTTP {response.status_code}" error_code = self._extract_error_code(payload) headers = dict(response.headers) + metadata = { + "method": response.request.method, + "path": response.request.url.path, + } if response.status_code == 401: return AuthenticationError( - message, status_code=401, error_code=error_code, payload=payload, headers=headers + message, + status_code=401, + error_code=error_code, + operation=operation, + metadata=metadata, + payload=payload, + headers=headers, ) if response.status_code == 403: - return PermissionDeniedError( - message, status_code=403, error_code=error_code, payload=payload, headers=headers + return AuthorizationError( + message, + status_code=403, + error_code=error_code, + operation=operation, + metadata=metadata, + payload=payload, + headers=headers, ) if response.status_code == 404: return NotFoundError( - message, status_code=404, error_code=error_code, payload=payload, headers=headers + message, + status_code=404, + error_code=error_code, + operation=operation, + metadata=metadata, + payload=payload, + headers=headers, ) - if response.status_code == 422: + if response.status_code in {400, 422}: return ValidationError( - message, status_code=422, error_code=error_code, payload=payload, headers=headers + message, + status_code=response.status_code, + error_code=error_code, + operation=operation, + metadata=metadata, + payload=payload, + headers=headers, + ) + if response.status_code == 409: + return ConflictError( + message, + status_code=409, + error_code=error_code, + operation=operation, + metadata=metadata, + payload=payload, + headers=headers, ) if response.status_code == 429: return RateLimitError( - message, status_code=429, error_code=error_code, payload=payload, headers=headers + message, + status_code=429, + error_code=error_code, + operation=operation, + metadata=metadata, + payload=payload, + headers=headers, + ) + if response.status_code in {405, 501}: + return UnsupportedOperationError( + message, + status_code=response.status_code, + error_code=error_code, + operation=operation, + metadata=metadata, + payload=payload, + headers=headers, ) - if 400 <= response.status_code < 500: - return ClientError( + if response.status_code >= 500: + return ServerError( message, status_code=response.status_code, error_code=error_code, + operation=operation, + metadata=metadata, payload=payload, headers=headers, ) - return ServerError( + return UpstreamApiError( message, status_code=response.status_code, error_code=error_code, + operation=operation, + metadata=metadata, payload=payload, headers=headers, ) diff --git a/avito/core/types.py b/avito/core/types.py index 6b0bdfe..3a678da 100644 --- a/avito/core/types.py +++ b/avito/core/types.py @@ -53,6 +53,7 @@ class TransportDebugInfo: """Безопасный снимок transport-конфигурации для диагностики интеграции.""" base_url: str + user_id: int | None requires_auth: bool timeout_connect: float timeout_read: float diff --git a/avito/cpa/__init__.py b/avito/cpa/__init__.py index 98d556e..616cc96 100644 --- a/avito/cpa/__init__.py +++ b/avito/cpa/__init__.py @@ -3,38 +3,56 @@ from avito.cpa.domain import CallTrackingCall, CpaCall, CpaChat, CpaLead, CpaLegacy, DomainObject from avito.cpa.models import ( CallTrackingCallInfo, + CallTrackingCallResponse, + CallTrackingCallsRequest, CallTrackingCallsResult, + CallTrackingGetCallByIdRequest, CallTrackingRecord, CpaActionResult, CpaAudioRecord, CpaBalanceInfo, + CpaCallByIdRequest, + CpaCallComplaintRequest, CpaCallInfo, + CpaCallsByTimeRequest, CpaCallsResult, CpaChatInfo, + CpaChatsByTimeRequest, CpaChatsResult, CpaErrorInfo, + CpaLeadComplaintRequest, CpaPhoneInfo, + CpaPhonesFromChatsRequest, CpaPhonesResult, ) __all__ = ( "CallTrackingCall", "CallTrackingCallInfo", + "CallTrackingCallResponse", + "CallTrackingCallsRequest", "CallTrackingCallsResult", + "CallTrackingGetCallByIdRequest", "CallTrackingRecord", "CpaActionResult", "CpaAudioRecord", "CpaBalanceInfo", "CpaCall", + "CpaCallByIdRequest", + "CpaCallComplaintRequest", "CpaCallInfo", + "CpaCallsByTimeRequest", "CpaCallsResult", "CpaChat", "CpaChatInfo", + "CpaChatsByTimeRequest", "CpaChatsResult", "CpaErrorInfo", "CpaLead", + "CpaLeadComplaintRequest", "CpaLegacy", "CpaPhoneInfo", + "CpaPhonesFromChatsRequest", "CpaPhonesResult", "DomainObject", ) diff --git a/avito/cpa/client.py b/avito/cpa/client.py index 58dc799..a3994b7 100644 --- a/avito/cpa/client.py +++ b/avito/cpa/client.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from avito.core import RequestContext, Transport +from avito.core.mapping import request_public_model from avito.cpa.mappers import ( map_balance, map_call_item, @@ -17,18 +18,25 @@ map_phones, ) from avito.cpa.models import ( - CallTrackingCallInfo, + CallTrackingCallResponse, + CallTrackingCallsRequest, CallTrackingCallsResult, + CallTrackingGetCallByIdRequest, CallTrackingRecord, CpaActionResult, CpaAudioRecord, CpaBalanceInfo, + CpaCallByIdRequest, + CpaCallComplaintRequest, CpaCallInfo, + CpaCallsByTimeRequest, CpaCallsResult, CpaChatInfo, + CpaChatsByTimeRequest, CpaChatsResult, + CpaLeadComplaintRequest, + CpaPhonesFromChatsRequest, CpaPhonesResult, - JsonRequest, ) @@ -39,39 +47,43 @@ class CpaChatsClient: transport: Transport def get_by_action_id(self, *, action_id: int | str) -> CpaChatInfo: - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/cpa/v1/chatByActionId/{action_id}", context=RequestContext("cpa.chats.get_by_action_id"), + mapper=map_chat_item, ) - return map_chat_item(payload) - def list_by_time_v1(self, request: JsonRequest) -> CpaChatsResult: - payload = self.transport.request_json( + def list_by_time_v1(self, request: CpaChatsByTimeRequest) -> CpaChatsResult: + return request_public_model( + self.transport, "POST", "/cpa/v1/chatsByTime", context=RequestContext("cpa.chats.list_by_time_v1", allow_retry=True), + mapper=map_chats, json_body=request.to_payload(), ) - return map_chats(payload) - def list_by_time_v2(self, request: JsonRequest) -> CpaChatsResult: - payload = self.transport.request_json( + def list_by_time_v2(self, request: CpaChatsByTimeRequest) -> CpaChatsResult: + return request_public_model( + self.transport, "POST", "/cpa/v2/chatsByTime", context=RequestContext("cpa.chats.list_by_time_v2", allow_retry=True), + mapper=map_chats, json_body=request.to_payload(), ) - return map_chats(payload) - def get_phones_info(self, request: JsonRequest) -> CpaPhonesResult: - payload = self.transport.request_json( + def get_phones_info(self, request: CpaPhonesFromChatsRequest) -> CpaPhonesResult: + return request_public_model( + self.transport, "POST", "/cpa/v1/phonesInfoFromChats", context=RequestContext("cpa.chats.get_phones_info", allow_retry=True), + mapper=map_phones, json_body=request.to_payload(), ) - return map_phones(payload) @dataclass(slots=True) @@ -80,23 +92,25 @@ class CpaCallsClient: transport: Transport - def list_by_time_v2(self, request: JsonRequest) -> CpaCallsResult: - payload = self.transport.request_json( + def list_by_time_v2(self, request: CpaCallsByTimeRequest) -> CpaCallsResult: + return request_public_model( + self.transport, "POST", "/cpa/v2/callsByTime", context=RequestContext("cpa.calls.list_by_time_v2", allow_retry=True), + mapper=map_calls, json_body=request.to_payload(), ) - return map_calls(payload) - def create_complaint(self, request: JsonRequest) -> CpaActionResult: - payload = self.transport.request_json( + def create_complaint(self, request: CpaCallComplaintRequest) -> CpaActionResult: + return request_public_model( + self.transport, "POST", "/cpa/v1/createComplaint", context=RequestContext("cpa.calls.create_complaint", allow_retry=True), + mapper=map_cpa_action, json_body=request.to_payload(), ) - return map_cpa_action(payload) @dataclass(slots=True) @@ -105,23 +119,25 @@ class CpaLeadsClient: transport: Transport - def create_complaint_by_action_id(self, request: JsonRequest) -> CpaActionResult: - payload = self.transport.request_json( + def create_complaint_by_action_id(self, request: CpaLeadComplaintRequest) -> CpaActionResult: + return request_public_model( + self.transport, "POST", "/cpa/v1/createComplaintByActionId", context=RequestContext("cpa.leads.create_complaint_by_action_id", allow_retry=True), + mapper=map_cpa_action, json_body=request.to_payload(), ) - return map_cpa_action(payload) - def get_balance_info_v3(self, request: JsonRequest) -> CpaBalanceInfo: - payload = self.transport.request_json( + def get_balance_info_v3(self) -> CpaBalanceInfo: + return request_public_model( + self.transport, "POST", "/cpa/v3/balanceInfo", context=RequestContext("cpa.leads.get_balance_info_v3", allow_retry=True), - json_body=request.to_payload(), + mapper=map_balance, + json_body={}, ) - return map_balance(payload) @dataclass(slots=True) @@ -137,23 +153,25 @@ def get_record(self, *, call_id: int | str) -> CpaAudioRecord: ) return CpaAudioRecord(binary) - def get_balance_info_v2(self, request: JsonRequest) -> CpaBalanceInfo: - payload = self.transport.request_json( + def get_balance_info_v2(self) -> CpaBalanceInfo: + return request_public_model( + self.transport, "POST", "/cpa/v2/balanceInfo", context=RequestContext("cpa.legacy.get_balance_info_v2", allow_retry=True), - json_body=request.to_payload(), + mapper=map_balance, + json_body={}, ) - return map_balance(payload) - def get_call_by_id_v2(self, request: JsonRequest) -> CpaCallInfo: - payload = self.transport.request_json( + def get_call_by_id_v2(self, request: CpaCallByIdRequest) -> CpaCallInfo: + return request_public_model( + self.transport, "POST", "/cpa/v2/callById", context=RequestContext("cpa.legacy.get_call_by_id_v2", allow_retry=True), + mapper=map_call_item, json_body=request.to_payload(), ) - return map_call_item(payload) @dataclass(slots=True) @@ -162,23 +180,25 @@ class CallTrackingClient: transport: Transport - def get_call_by_id(self, request: JsonRequest) -> CallTrackingCallInfo: - payload = self.transport.request_json( + def get_call_by_id(self, request: CallTrackingGetCallByIdRequest) -> CallTrackingCallResponse: + return request_public_model( + self.transport, "POST", "/calltracking/v1/getCallById/", context=RequestContext("cpa.calltracking.get_call_by_id", allow_retry=True), + mapper=map_call_tracking_call_item, json_body=request.to_payload(), ) - return map_call_tracking_call_item(payload) - def get_calls(self, request: JsonRequest) -> CallTrackingCallsResult: - payload = self.transport.request_json( + def get_calls(self, request: CallTrackingCallsRequest) -> CallTrackingCallsResult: + return request_public_model( + self.transport, "POST", "/calltracking/v1/getCalls/", context=RequestContext("cpa.calltracking.get_calls", allow_retry=True), + mapper=map_call_tracking_calls, json_body=request.to_payload(), ) - return map_call_tracking_calls(payload) def get_record_by_call_id(self, *, call_id: int | str) -> CallTrackingRecord: binary = self.transport.download_binary( diff --git a/avito/cpa/domain.py b/avito/cpa/domain.py index 93ff7bc..288eefd 100644 --- a/avito/cpa/domain.py +++ b/avito/cpa/domain.py @@ -2,10 +2,9 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass -from avito.core import Transport +from avito.core import Transport, ValidationError from avito.cpa.client import ( CallTrackingClient, CpaCallsClient, @@ -14,18 +13,25 @@ CpaLegacyClient, ) from avito.cpa.models import ( - CallTrackingCallInfo, + CallTrackingCallResponse, + CallTrackingCallsRequest, CallTrackingCallsResult, + CallTrackingGetCallByIdRequest, CallTrackingRecord, CpaActionResult, CpaAudioRecord, CpaBalanceInfo, + CpaCallByIdRequest, + CpaCallComplaintRequest, CpaCallInfo, + CpaCallsByTimeRequest, CpaCallsResult, CpaChatInfo, + CpaChatsByTimeRequest, CpaChatsResult, + CpaLeadComplaintRequest, + CpaPhonesFromChatsRequest, CpaPhonesResult, - JsonRequest, ) @@ -43,13 +49,15 @@ class CpaLead(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def create_complaint_by_action_id(self, *, payload: Mapping[str, object]) -> CpaActionResult: - return CpaLeadsClient(self.transport).create_complaint_by_action_id(JsonRequest(payload)) + def create_complaint_by_action_id( + self, + *, + request: CpaLeadComplaintRequest, + ) -> CpaActionResult: + return CpaLeadsClient(self.transport).create_complaint_by_action_id(request) - def create_balance_info_v3( - self, *, payload: Mapping[str, object] | None = None - ) -> CpaBalanceInfo: - return CpaLeadsClient(self.transport).get_balance_info_v3(JsonRequest(payload or {})) + def create_balance_info_v3(self) -> CpaBalanceInfo: + return CpaLeadsClient(self.transport).get_balance_info_v3() @dataclass(slots=True, frozen=True) @@ -67,21 +75,24 @@ def get(self, *, action_id: int | str | None = None) -> CpaChatInfo: def list( self, *, - payload: Mapping[str, object], + request: CpaChatsByTimeRequest, version: int = 2, ) -> CpaChatsResult: client = CpaChatsClient(self.transport) - request = JsonRequest(payload) if version == 1: return client.list_by_time_v1(request) return client.list_by_time_v2(request) - def get_phones_info_from_chats(self, *, payload: Mapping[str, object]) -> CpaPhonesResult: - return CpaChatsClient(self.transport).get_phones_info(JsonRequest(payload)) + def get_phones_info_from_chats( + self, + *, + request: CpaPhonesFromChatsRequest, + ) -> CpaPhonesResult: + return CpaChatsClient(self.transport).get_phones_info(request) def _require_resource_id(self) -> str: if self.resource_id is None: - raise ValueError("Для операции требуется `action_id` или `chat_id`.") + raise ValidationError("Для операции требуется `action_id` или `chat_id`.") return str(self.resource_id) @@ -92,11 +103,11 @@ class CpaCall(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def list(self, *, payload: Mapping[str, object]) -> CpaCallsResult: - return CpaCallsClient(self.transport).list_by_time_v2(JsonRequest(payload)) + def list(self, *, request: CpaCallsByTimeRequest) -> CpaCallsResult: + return CpaCallsClient(self.transport).list_by_time_v2(request) - def create_create_complaint(self, *, payload: Mapping[str, object]) -> CpaActionResult: - return CpaCallsClient(self.transport).create_complaint(JsonRequest(payload)) + def create_complaint(self, *, request: CpaCallComplaintRequest) -> CpaActionResult: + return CpaCallsClient(self.transport).create_complaint(request) @dataclass(slots=True, frozen=True) @@ -106,24 +117,20 @@ class CpaLegacy(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def legacy_get_call(self, *, call_id: int | str | None = None) -> CpaAudioRecord: + def get_call(self, *, call_id: int | str | None = None) -> CpaAudioRecord: return CpaLegacyClient(self.transport).get_record( call_id=call_id or self._require_resource_id() ) - def legacy_create_balance_info_v2( - self, - *, - payload: Mapping[str, object] | None = None, - ) -> CpaBalanceInfo: - return CpaLegacyClient(self.transport).get_balance_info_v2(JsonRequest(payload or {})) + def get_balance_info_v2(self) -> CpaBalanceInfo: + return CpaLegacyClient(self.transport).get_balance_info_v2() - def legacy_create_call_by_id_v2(self, *, payload: Mapping[str, object]) -> CpaCallInfo: - return CpaLegacyClient(self.transport).get_call_by_id_v2(JsonRequest(payload)) + def get_call_by_id_v2(self, *, request: CpaCallByIdRequest) -> CpaCallInfo: + return CpaLegacyClient(self.transport).get_call_by_id_v2(request) def _require_resource_id(self) -> str: if self.resource_id is None: - raise ValueError("Для операции требуется `call_id`.") + raise ValidationError("Для операции требуется `call_id`.") return str(self.resource_id) @@ -134,14 +141,16 @@ class CallTrackingCall(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def get(self, *, payload: Mapping[str, object] | None = None) -> CallTrackingCallInfo: - request_payload = dict(payload or {}) - if "callId" not in request_payload and self.resource_id is not None: - request_payload["callId"] = self.resource_id - return CallTrackingClient(self.transport).get_call_by_id(JsonRequest(request_payload)) + def get(self, *, call_id: int | None = None) -> CallTrackingCallResponse: + resolved_call_id = call_id or (int(self.resource_id) if self.resource_id is not None else None) + if resolved_call_id is None: + raise ValidationError("Для операции требуется `call_id`.") + return CallTrackingClient(self.transport).get_call_by_id( + CallTrackingGetCallByIdRequest(call_id=resolved_call_id) + ) - def list(self, *, payload: Mapping[str, object]) -> CallTrackingCallsResult: - return CallTrackingClient(self.transport).get_calls(JsonRequest(payload)) + def list(self, *, request: CallTrackingCallsRequest) -> CallTrackingCallsResult: + return CallTrackingClient(self.transport).get_calls(request) def download(self, *, call_id: int | str | None = None) -> CallTrackingRecord: return CallTrackingClient(self.transport).get_record_by_call_id( @@ -150,7 +159,7 @@ def download(self, *, call_id: int | str | None = None) -> CallTrackingRecord: def _require_resource_id(self) -> str: if self.resource_id is None: - raise ValueError("Для операции требуется `call_id`.") + raise ValidationError("Для операции требуется `call_id`.") return str(self.resource_id) diff --git a/avito/cpa/mappers.py b/avito/cpa/mappers.py index b767245..3547d68 100644 --- a/avito/cpa/mappers.py +++ b/avito/cpa/mappers.py @@ -8,6 +8,7 @@ from avito.core.exceptions import ResponseMappingError from avito.cpa.models import ( CallTrackingCallInfo, + CallTrackingCallResponse, CallTrackingCallsResult, CpaActionResult, CpaBalanceInfo, @@ -94,7 +95,6 @@ def map_cpa_error(payload: object | None) -> CpaErrorInfo | None: return CpaErrorInfo( code=_int(data, "code"), message=_str(data, "message", "error"), - raw_payload=data, ) @@ -105,7 +105,6 @@ def map_cpa_action(payload: object) -> CpaActionResult: return CpaActionResult( success=bool(data.get("success", False)), error=map_cpa_error(data.get("error")), - raw_payload=data, ) @@ -118,7 +117,6 @@ def map_balance(payload: object) -> CpaBalanceInfo: advance=_int(data, "advance"), debt=_int(data, "debt"), error=map_cpa_error(data.get("error")), - raw_payload=data, ) @@ -138,7 +136,6 @@ def _map_cpa_call(item: Payload) -> CpaCallInfo: group_title=_str(item, "groupTitle"), record_url=_str(item, "recordUrl"), is_arbitrage_available=_bool(item, "isArbitrageAvailable"), - raw_payload=item, ) @@ -158,7 +155,6 @@ def map_calls(payload: object) -> CpaCallsResult: return CpaCallsResult( items=[_map_cpa_call(item) for item in _list(data, "calls", "items", "results")], error=map_cpa_error(data.get("error")), - raw_payload=data, ) @@ -177,7 +173,6 @@ def _map_cpa_chat(item: Payload) -> CpaChatInfo: created_at=_str(source, "createdAt", "created_at"), updated_at=_str(source, "updatedAt", "updated_at"), is_arbitrage_available=_bool(item, "isArbitrageAvailable"), - raw_payload=item, ) @@ -197,7 +192,6 @@ def map_chats(payload: object) -> CpaChatsResult: data = _expect_mapping(payload) return CpaChatsResult( items=[_map_cpa_chat(item) for item in _list(data, "chats", "items", "results")], - raw_payload=data, ) @@ -214,12 +208,10 @@ def map_phones(payload: object) -> CpaPhonesResult: price=_int(item, "pricePenny", "price"), group=_str(item, "group"), preview_url=_str(item, "url", "previewUrl"), - raw_payload=item, ) for item in _list(data, "results", "items") ], total=_int(data, "total"), - raw_payload=data, ) @@ -233,17 +225,21 @@ def _map_call_tracking_call(item: Payload) -> CallTrackingCallInfo: call_time=_str(item, "callTime", "createTime"), talk_duration=_int(item, "talkDuration", "duration"), waiting_duration=_float(item, "waitingDuration"), - raw_payload=item, ) -def map_call_tracking_call_item(payload: object) -> CallTrackingCallInfo: +def map_call_tracking_call_item(payload: object) -> CallTrackingCallResponse: """Преобразует один звонок CallTracking.""" data = _expect_mapping(payload) call = _mapping(data, "call") - source = call or data - return _map_call_tracking_call(source) + error = map_cpa_error(data.get("error")) + if not call or error is None: + raise ResponseMappingError( + "Ответ CallTracking getCallById должен содержать `call` и `error`.", + payload=payload, + ) + return CallTrackingCallResponse(call=_map_call_tracking_call(call), error=error) def map_call_tracking_calls(payload: object) -> CallTrackingCallsResult: @@ -253,5 +249,4 @@ def map_call_tracking_calls(payload: object) -> CallTrackingCallsResult: return CallTrackingCallsResult( items=[_map_call_tracking_call(item) for item in _list(data, "calls", "items", "results")], error=map_cpa_error(data.get("error")), - raw_payload=data, ) diff --git a/avito/cpa/models.py b/avito/cpa/models.py index fee2c91..619b967 100644 --- a/avito/cpa/models.py +++ b/avito/cpa/models.py @@ -2,55 +2,133 @@ from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass, field +from base64 import b64encode +from dataclasses import dataclass +from typing import Any from avito.core import BinaryResponse +from avito.core.serialization import SerializableModel, enable_module_serialization @dataclass(slots=True, frozen=True) -class JsonRequest: - """Типизированная обертка над JSON payload запроса.""" +class CpaChatsByTimeRequest: + """Запрос списка CPA-чатов по времени.""" - payload: Mapping[str, object] + created_at_from: str + limit: int | None = None def to_payload(self) -> dict[str, object]: - """Сериализует payload запроса.""" + payload: dict[str, object] = {"createdAtFrom": self.created_at_from} + if self.limit is not None: + payload["limit"] = self.limit + return payload - return dict(self.payload) + +@dataclass(slots=True, frozen=True) +class CpaPhonesFromChatsRequest: + """Запрос телефонов из целевых чатов.""" + + action_ids: list[str] + + def to_payload(self) -> dict[str, object]: + return {"actionIds": list(self.action_ids)} + + +@dataclass(slots=True, frozen=True) +class CpaCallsByTimeRequest: + """Запрос списка CPA-звонков по времени.""" + + date_time_from: str + date_time_to: str + + def to_payload(self) -> dict[str, object]: + return { + "dateTimeFrom": self.date_time_from, + "dateTimeTo": self.date_time_to, + } + + +@dataclass(slots=True, frozen=True) +class CpaCallComplaintRequest: + """Запрос жалобы на CPA-звонок.""" + + call_id: int + reason: str + + def to_payload(self) -> dict[str, object]: + return {"callId": self.call_id, "reason": self.reason} @dataclass(slots=True, frozen=True) -class CpaErrorInfo: +class CpaLeadComplaintRequest: + """Запрос жалобы по action id.""" + + action_id: str + reason: str + + def to_payload(self) -> dict[str, object]: + return {"actionId": self.action_id, "reason": self.reason} + + +@dataclass(slots=True, frozen=True) +class CpaCallByIdRequest: + """Запрос получения CPA-звонка по идентификатору.""" + + call_id: int + + def to_payload(self) -> dict[str, int]: + return {"callId": self.call_id} + + +@dataclass(slots=True, frozen=True) +class CallTrackingCallsRequest: + """Запрос списка звонков CallTracking.""" + + date_time_from: str + date_time_to: str + limit: int | None = None + offset: int | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = { + "dateTimeFrom": self.date_time_from, + "dateTimeTo": self.date_time_to, + } + if self.limit is not None: + payload["limit"] = self.limit + if self.offset is not None: + payload["offset"] = self.offset + return payload + + +@dataclass(slots=True, frozen=True) +class CpaErrorInfo(SerializableModel): """Информация об ошибке CPA API.""" code: int | None message: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaActionResult: +class CpaActionResult(SerializableModel): """Результат mutation-операции CPA.""" success: bool error: CpaErrorInfo | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaBalanceInfo: +class CpaBalanceInfo(SerializableModel): """Информация о CPA-балансе пользователя.""" balance: int | None advance: int | None = None debt: int | None = None error: CpaErrorInfo | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaCallInfo: +class CpaCallInfo(SerializableModel): """Информация о звонке CPA.""" call_id: str | None @@ -67,20 +145,18 @@ class CpaCallInfo: group_title: str | None record_url: str | None is_arbitrage_available: bool | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaCallsResult: +class CpaCallsResult(SerializableModel): """Список звонков CPA.""" items: list[CpaCallInfo] error: CpaErrorInfo | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaChatInfo: +class CpaChatInfo(SerializableModel): """Информация о CPA-чате.""" chat_id: str | None @@ -92,19 +168,17 @@ class CpaChatInfo: created_at: str | None updated_at: str | None is_arbitrage_available: bool | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaChatsResult: +class CpaChatsResult(SerializableModel): """Список чатов CPA.""" items: list[CpaChatInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaPhoneInfo: +class CpaPhoneInfo(SerializableModel): """Информация по телефону, найденному в целевом чате.""" action_id: str | None @@ -113,16 +187,14 @@ class CpaPhoneInfo: price: int | None group: str | None preview_url: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaPhonesResult: +class CpaPhonesResult(SerializableModel): """Список телефонных номеров из целевых чатов.""" items: list[CpaPhoneInfo] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -137,9 +209,21 @@ def filename(self) -> str | None: return self.binary.filename + def to_dict(self) -> dict[str, Any]: + """Сериализует бинарную запись без transport-объекта.""" + + return { + "filename": self.binary.filename, + "content_type": self.binary.content_type, + "content_base64": b64encode(self.binary.content).decode("ascii"), + } + + def model_dump(self) -> dict[str, Any]: + return self.to_dict() + @dataclass(slots=True, frozen=True) -class CallTrackingCallInfo: +class CallTrackingCallInfo(SerializableModel): """Информация о звонке CallTracking.""" call_id: str | None @@ -150,16 +234,34 @@ class CallTrackingCallInfo: call_time: str | None talk_duration: int | None waiting_duration: float | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CallTrackingCallsResult: +class CallTrackingCallsResult(SerializableModel): """Список звонков CallTracking.""" items: list[CallTrackingCallInfo] error: CpaErrorInfo | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(slots=True, frozen=True) +class CallTrackingGetCallByIdRequest: + """Запрос получения звонка CallTracking по идентификатору.""" + + call_id: int + + def to_payload(self) -> dict[str, int]: + """Сериализует запрос звонка CallTracking.""" + + return {"callId": self.call_id} + + +@dataclass(slots=True, frozen=True) +class CallTrackingCallResponse(SerializableModel): + """Ответ CallTracking get_call_by_id с объектом звонка и ошибкой.""" + + call: CallTrackingCallInfo + error: CpaErrorInfo @dataclass(slots=True, frozen=True) @@ -173,3 +275,18 @@ def filename(self) -> str | None: """Имя файла записи звонка.""" return self.binary.filename + + def to_dict(self) -> dict[str, Any]: + """Сериализует бинарную запись без transport-объекта.""" + + return { + "filename": self.binary.filename, + "content_type": self.binary.content_type, + "content_base64": b64encode(self.binary.content).decode("ascii"), + } + + def model_dump(self) -> dict[str, Any]: + return self.to_dict() + + +enable_module_serialization(globals()) diff --git a/avito/jobs/client.py b/avito/jobs/client.py index 8512170..83187c8 100644 --- a/avito/jobs/client.py +++ b/avito/jobs/client.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass from avito.core import RequestContext, Transport +from avito.core.mapping import request_public_model from avito.jobs.mappers import ( map_application_ids, map_application_states, @@ -29,9 +29,10 @@ JobActionResult, JobDictionariesResult, JobDictionaryValuesResult, + JobsQuery, + JobsRequest, JobWebhookInfo, JobWebhooksResult, - JsonRequest, ResumeContactInfo, ResumeInfo, ResumesResult, @@ -47,50 +48,54 @@ class ApplicationsClient: transport: Transport - def apply_actions(self, request: JsonRequest) -> JobActionResult: + def apply_actions(self, request: JobsRequest) -> JobActionResult: return self._post_action( "/job/v1/applications/apply_actions", "jobs.applications.apply_actions", request ) - def get_by_ids(self, request: JsonRequest) -> ApplicationsResult: - payload = self.transport.request_json( + def get_by_ids(self, request: JobsRequest) -> ApplicationsResult: + return request_public_model( + self.transport, "POST", "/job/v1/applications/get_by_ids", context=RequestContext("jobs.applications.get_by_ids", allow_retry=True), + mapper=map_applications, json_body=request.to_payload(), ) - return map_applications(payload) - def get_ids(self, *, params: Mapping[str, object]) -> ApplicationIdsResult: - payload = self.transport.request_json( + def get_ids(self, *, query: JobsQuery) -> ApplicationIdsResult: + return request_public_model( + self.transport, "GET", "/job/v1/applications/get_ids", context=RequestContext("jobs.applications.get_ids"), - params=params, + mapper=map_application_ids, + params=query.to_params(), ) - return map_application_ids(payload) def get_states(self) -> ApplicationStatesResult: - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/job/v1/applications/get_states", context=RequestContext("jobs.applications.get_states"), + mapper=map_application_states, ) - return map_application_states(payload) - def set_is_viewed(self, request: JsonRequest) -> JobActionResult: + def set_is_viewed(self, request: JobsRequest) -> JobActionResult: return self._post_action( "/job/v1/applications/set_is_viewed", "jobs.applications.set_is_viewed", request ) - def _post_action(self, path: str, operation: str, request: JsonRequest) -> JobActionResult: - payload = self.transport.request_json( + def _post_action(self, path: str, operation: str, request: JobsRequest) -> JobActionResult: + return request_public_model( + self.transport, "POST", path, context=RequestContext(operation, allow_retry=True), + mapper=map_job_action, json_body=request.to_payload(), ) - return map_job_action(payload) @dataclass(slots=True) @@ -100,38 +105,42 @@ class WebhookClient: transport: Transport def get_webhook(self) -> JobWebhookInfo: - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/job/v1/applications/webhook", context=RequestContext("jobs.webhook.get"), + mapper=map_job_webhook, ) - return map_job_webhook(payload) - def put_webhook(self, request: JsonRequest) -> JobWebhookInfo: - payload = self.transport.request_json( + def put_webhook(self, request: JobsRequest) -> JobWebhookInfo: + return request_public_model( + self.transport, "PUT", "/job/v1/applications/webhook", context=RequestContext("jobs.webhook.put", allow_retry=True), + mapper=map_job_webhook, json_body=request.to_payload(), ) - return map_job_webhook(payload) def delete_webhook(self, *, url: str | None = None) -> JobActionResult: - payload = self.transport.request_json( + return request_public_model( + self.transport, "DELETE", "/job/v1/applications/webhook", context=RequestContext("jobs.webhook.delete", allow_retry=True), + mapper=map_job_action, params={"url": url}, ) - return map_job_action(payload) def list_webhooks(self) -> JobWebhooksResult: - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/job/v1/applications/webhooks", context=RequestContext("jobs.webhook.list"), + mapper=map_job_webhooks, ) - return map_job_webhooks(payload) @dataclass(slots=True) @@ -140,30 +149,33 @@ class ResumeClient: transport: Transport - def search(self, *, params: Mapping[str, object] | None = None) -> ResumesResult: - payload = self.transport.request_json( + def search(self, *, query: JobsQuery | None = None) -> ResumesResult: + return request_public_model( + self.transport, "GET", "/job/v1/resumes/", context=RequestContext("jobs.resumes.search"), - params=params, + mapper=map_resumes, + params=query.to_params() if query is not None else None, ) - return map_resumes(payload) def get_contacts(self, *, resume_id: str) -> ResumeContactInfo: - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/job/v1/resumes/{resume_id}/contacts/", context=RequestContext("jobs.resumes.get_contacts"), + mapper=map_resume_contacts, ) - return map_resume_contacts(payload) def get_item(self, *, resume_id: str) -> ResumeInfo: - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/job/v2/resumes/{resume_id}", context=RequestContext("jobs.resumes.get_item"), + mapper=map_resume_item, ) - return map_resume_item(payload) @dataclass(slots=True) @@ -172,106 +184,117 @@ class VacanciesClient: transport: Transport - def create_v1(self, request: JsonRequest) -> JobActionResult: - payload = self.transport.request_json( + def create_v1(self, request: JobsRequest) -> JobActionResult: + return request_public_model( + self.transport, "POST", "/job/v1/vacancies", context=RequestContext("jobs.vacancies.create_v1", allow_retry=True), + mapper=map_job_action, json_body=request.to_payload(), ) - return map_job_action(payload) - def archive_v1(self, *, vacancy_id: int | str, request: JsonRequest) -> JobActionResult: - payload = self.transport.request_json( + def archive_v1(self, *, vacancy_id: int | str, request: JobsRequest) -> JobActionResult: + return request_public_model( + self.transport, "PUT", f"/job/v1/vacancies/archived/{vacancy_id}", context=RequestContext("jobs.vacancies.archive_v1", allow_retry=True), + mapper=map_job_action, json_body=request.to_payload(), ) - return map_job_action(payload) - def update_v1(self, *, vacancy_id: int | str, request: JsonRequest) -> JobActionResult: - payload = self.transport.request_json( + def update_v1(self, *, vacancy_id: int | str, request: JobsRequest) -> JobActionResult: + return request_public_model( + self.transport, "PUT", f"/job/v1/vacancies/{vacancy_id}", context=RequestContext("jobs.vacancies.update_v1", allow_retry=True), + mapper=map_job_action, json_body=request.to_payload(), ) - return map_job_action(payload) - def prolongate_v1(self, *, vacancy_id: int | str, request: JsonRequest) -> JobActionResult: - payload = self.transport.request_json( + def prolongate_v1(self, *, vacancy_id: int | str, request: JobsRequest) -> JobActionResult: + return request_public_model( + self.transport, "POST", f"/job/v1/vacancies/{vacancy_id}/prolongate", context=RequestContext("jobs.vacancies.prolongate_v1", allow_retry=True), + mapper=map_job_action, json_body=request.to_payload(), ) - return map_job_action(payload) - def list_v2(self, *, params: Mapping[str, object] | None = None) -> VacanciesResult: - payload = self.transport.request_json( + def list_v2(self, *, query: JobsQuery | None = None) -> VacanciesResult: + return request_public_model( + self.transport, "GET", "/job/v2/vacancies", context=RequestContext("jobs.vacancies.list_v2"), - params=params, + mapper=map_vacancies, + params=query.to_params() if query is not None else None, ) - return map_vacancies(payload) - def create_v2(self, request: JsonRequest) -> JobActionResult: - payload = self.transport.request_json( + def create_v2(self, request: JobsRequest) -> JobActionResult: + return request_public_model( + self.transport, "POST", "/job/v2/vacancies", context=RequestContext("jobs.vacancies.create_v2", allow_retry=True), + mapper=map_job_action, json_body=request.to_payload(), ) - return map_job_action(payload) - def get_by_ids_v2(self, request: JsonRequest) -> VacanciesResult: - payload = self.transport.request_json( + def get_by_ids_v2(self, request: JobsRequest) -> VacanciesResult: + return request_public_model( + self.transport, "POST", "/job/v2/vacancies/batch", context=RequestContext("jobs.vacancies.get_by_ids_v2", allow_retry=True), + mapper=map_vacancies, json_body=request.to_payload(), ) - return map_vacancies(payload) - def get_statuses_v2(self, request: JsonRequest) -> VacancyStatusesResult: - payload = self.transport.request_json( + def get_statuses_v2(self, request: JobsRequest) -> VacancyStatusesResult: + return request_public_model( + self.transport, "POST", "/job/v2/vacancies/statuses", context=RequestContext("jobs.vacancies.get_statuses_v2", allow_retry=True), + mapper=map_vacancy_statuses, json_body=request.to_payload(), ) - return map_vacancy_statuses(payload) - def update_v2(self, *, vacancy_uuid: str, request: JsonRequest) -> JobActionResult: - payload = self.transport.request_json( + def update_v2(self, *, vacancy_uuid: str, request: JobsRequest) -> JobActionResult: + return request_public_model( + self.transport, "POST", f"/job/v2/vacancies/update/{vacancy_uuid}", context=RequestContext("jobs.vacancies.update_v2", allow_retry=True), + mapper=map_job_action, json_body=request.to_payload(), ) - return map_job_action(payload) def get_item_v2( - self, *, vacancy_id: int | str, params: Mapping[str, object] | None = None + self, *, vacancy_id: int | str, query: JobsQuery | None = None ) -> VacancyInfo: - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/job/v2/vacancies/{vacancy_id}", context=RequestContext("jobs.vacancies.get_item_v2"), - params=params, + mapper=map_vacancy_item, + params=query.to_params() if query is not None else None, ) - return map_vacancy_item(payload) - def auto_renewal_v2(self, *, vacancy_uuid: str, request: JsonRequest) -> JobActionResult: - payload = self.transport.request_json( + def auto_renewal_v2(self, *, vacancy_uuid: str, request: JobsRequest) -> JobActionResult: + return request_public_model( + self.transport, "PUT", f"/job/v2/vacancies/{vacancy_uuid}/auto_renewal", context=RequestContext("jobs.vacancies.auto_renewal_v2", allow_retry=True), + mapper=map_job_action, json_body=request.to_payload(), ) - return map_job_action(payload) @dataclass(slots=True) @@ -281,20 +304,22 @@ class DictionariesClient: transport: Transport def list_dicts(self) -> JobDictionariesResult: - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/job/v2/vacancy/dict", context=RequestContext("jobs.dictionaries.list_dicts"), + mapper=map_job_dictionaries, ) - return map_job_dictionaries(payload) def get_dict_by_id(self, *, dictionary_id: str) -> JobDictionaryValuesResult: - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/job/v2/vacancy/dict/{dictionary_id}", context=RequestContext("jobs.dictionaries.get_dict_by_id"), + mapper=map_job_dictionary_values, ) - return map_job_dictionary_values(payload) __all__ = ( diff --git a/avito/jobs/domain.py b/avito/jobs/domain.py index 099e96b..ecf9e44 100644 --- a/avito/jobs/domain.py +++ b/avito/jobs/domain.py @@ -2,10 +2,9 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass -from avito.core import Transport +from avito.core import Transport, ValidationError from avito.jobs.client import ( ApplicationsClient, DictionariesClient, @@ -14,13 +13,16 @@ WebhookClient, ) from avito.jobs.models import ( + ApplicationIdsResult, + ApplicationsResult, ApplicationStatesResult, JobActionResult, JobDictionariesResult, JobDictionaryValuesResult, + JobsQuery, + JobsRequest, JobWebhookInfo, JobWebhooksResult, - JsonRequest, ResumeContactInfo, ResumeInfo, ResumesResult, @@ -44,9 +46,8 @@ class Vacancy(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def create(self, *, payload: Mapping[str, object], version: int = 2) -> JobActionResult: + def create(self, *, request: JobsRequest, version: int = 2) -> JobActionResult: client = VacanciesClient(self.transport) - request = JsonRequest(payload) if version == 1: return client.create_v1(request) return client.create_v2(request) @@ -54,13 +55,12 @@ def create(self, *, payload: Mapping[str, object], version: int = 2) -> JobActio def update( self, *, - payload: Mapping[str, object], + request: JobsRequest, vacancy_id: int | str | None = None, vacancy_uuid: str | None = None, version: int = 2, ) -> JobActionResult: client = VacanciesClient(self.transport) - request = JsonRequest(payload) if version == 1: return client.update_v1( vacancy_id=vacancy_id or self._require_resource_id(), request=request @@ -70,49 +70,49 @@ def update( ) def delete( - self, *, payload: Mapping[str, object], vacancy_id: int | str | None = None + self, *, request: JobsRequest, vacancy_id: int | str | None = None ) -> JobActionResult: return VacanciesClient(self.transport).archive_v1( vacancy_id=vacancy_id or self._require_resource_id(), - request=JsonRequest(payload), + request=request, ) def prolongate( - self, *, payload: Mapping[str, object], vacancy_id: int | str | None = None + self, *, request: JobsRequest, vacancy_id: int | str | None = None ) -> JobActionResult: return VacanciesClient(self.transport).prolongate_v1( vacancy_id=vacancy_id or self._require_resource_id(), - request=JsonRequest(payload), + request=request, ) - def list(self, *, params: Mapping[str, object] | None = None) -> VacanciesResult: - return VacanciesClient(self.transport).list_v2(params=params) + def list(self, *, query: JobsQuery | None = None) -> VacanciesResult: + return VacanciesClient(self.transport).list_v2(query=query) def get( - self, *, vacancy_id: int | str | None = None, params: Mapping[str, object] | None = None + self, *, vacancy_id: int | str | None = None, query: JobsQuery | None = None ) -> VacancyInfo: return VacanciesClient(self.transport).get_item_v2( vacancy_id=vacancy_id or self._require_resource_id(), - params=params, + query=query, ) - def get_by_ids(self, *, payload: Mapping[str, object]) -> VacanciesResult: - return VacanciesClient(self.transport).get_by_ids_v2(JsonRequest(payload)) + def get_by_ids(self, *, request: JobsRequest) -> VacanciesResult: + return VacanciesClient(self.transport).get_by_ids_v2(request) - def get_statuses(self, *, payload: Mapping[str, object]) -> VacancyStatusesResult: - return VacanciesClient(self.transport).get_statuses_v2(JsonRequest(payload)) + def get_statuses(self, *, request: JobsRequest) -> VacancyStatusesResult: + return VacanciesClient(self.transport).get_statuses_v2(request) def update_auto_renewal( - self, *, payload: Mapping[str, object], vacancy_uuid: str | None = None + self, *, request: JobsRequest, vacancy_uuid: str | None = None ) -> JobActionResult: return VacanciesClient(self.transport).auto_renewal_v2( vacancy_uuid=vacancy_uuid or self._require_resource_id(), - request=JsonRequest(payload), + request=request, ) def _require_resource_id(self) -> str: if self.resource_id is None: - raise ValueError("Для операции требуется идентификатор вакансии.") + raise ValidationError("Для операции требуется идентификатор вакансии.") return str(self.resource_id) @@ -123,25 +123,25 @@ class Application(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def apply(self, *, payload: Mapping[str, object]) -> JobActionResult: - return ApplicationsClient(self.transport).apply_actions(JsonRequest(payload)) + def apply(self, *, request: JobsRequest) -> JobActionResult: + return ApplicationsClient(self.transport).apply_actions(request) def list( self, *, - payload: Mapping[str, object] | None = None, - params: Mapping[str, object] | None = None, - ) -> object: + request: JobsRequest | None = None, + query: JobsQuery | None = None, + ) -> ApplicationsResult | ApplicationIdsResult: client = ApplicationsClient(self.transport) - if payload is not None: - return client.get_by_ids(JsonRequest(payload)) - return client.get_ids(params=params or {}) + if request is not None: + return client.get_by_ids(request) + return client.get_ids(query=query or JobsQuery(params={})) def get_states(self) -> ApplicationStatesResult: return ApplicationsClient(self.transport).get_states() - def update(self, *, payload: Mapping[str, object]) -> JobActionResult: - return ApplicationsClient(self.transport).set_is_viewed(JsonRequest(payload)) + def update(self, *, request: JobsRequest) -> JobActionResult: + return ApplicationsClient(self.transport).set_is_viewed(request) @dataclass(slots=True, frozen=True) @@ -151,8 +151,8 @@ class Resume(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def list(self, *, params: Mapping[str, object] | None = None) -> ResumesResult: - return ResumeClient(self.transport).search(params=params) + def list(self, *, query: JobsQuery | None = None) -> ResumesResult: + return ResumeClient(self.transport).search(query=query) def get(self, *, resume_id: int | str | None = None) -> ResumeInfo: return ResumeClient(self.transport).get_item( @@ -166,7 +166,7 @@ def get_contacts(self, *, resume_id: int | str | None = None) -> ResumeContactIn def _require_resource_id(self) -> str: if self.resource_id is None: - raise ValueError("Для операции требуется `resume_id`.") + raise ValidationError("Для операции требуется `resume_id`.") return str(self.resource_id) @@ -183,8 +183,8 @@ def get(self) -> JobWebhookInfo: def list(self) -> JobWebhooksResult: return WebhookClient(self.transport).list_webhooks() - def update(self, *, payload: Mapping[str, object]) -> JobWebhookInfo: - return WebhookClient(self.transport).put_webhook(JsonRequest(payload)) + def update(self, *, request: JobsRequest) -> JobWebhookInfo: + return WebhookClient(self.transport).put_webhook(request) def delete(self, *, url: str | None = None) -> JobActionResult: return WebhookClient(self.transport).delete_webhook(url=url) @@ -207,7 +207,7 @@ def get(self, *, dictionary_id: str | None = None) -> JobDictionaryValuesResult: def _require_resource_id(self) -> str: if self.resource_id is None: - raise ValueError("Для операции требуется `dictionary_id`.") + raise ValidationError("Для операции требуется `dictionary_id`.") return str(self.resource_id) diff --git a/avito/jobs/mappers.py b/avito/jobs/mappers.py index bfc8704..22521f8 100644 --- a/avito/jobs/mappers.py +++ b/avito/jobs/mappers.py @@ -99,7 +99,7 @@ def map_job_action(payload: object) -> JobActionResult: id=identifier or (str(numeric_id) if numeric_id is not None else None), status=_str(source, "status", "state"), message=_str(source, "message"), - raw_payload=data, + _payload=data, ) @@ -111,7 +111,7 @@ def map_application(payload: Payload) -> ApplicationInfo: state=_str(payload, "state", "status"), is_viewed=_bool(payload, "is_viewed", "isViewed"), applicant_name=_str(_mapping(payload, "applicant"), "name", "fullName"), - raw_payload=payload, + _payload=payload, ) @@ -124,7 +124,7 @@ def map_applications(payload: object) -> ApplicationsResult: map_application(item) for item in _list(data, "applies", "applications", "items", "result") ], - raw_payload=data, + _payload=data, ) @@ -137,12 +137,12 @@ def map_application_ids(payload: object) -> ApplicationIdsResult: ApplicationIdItem( id=_str(item, "id"), updated_at=_str(item, "updatedAt", "updated_at"), - raw_payload=item, + _payload=item, ) for item in _list(data, "items", "applies", "result") ], cursor=_str(_mapping(data, "meta"), "cursor") or _str(data, "cursor"), - raw_payload=data, + _payload=data, ) @@ -155,11 +155,11 @@ def map_application_states(payload: object) -> ApplicationStatesResult: ApplicationState( slug=_str(item, "slug", "id"), description=_str(item, "description", "name"), - raw_payload=item, + _payload=item, ) for item in _list(data, "states", "items", "result") ], - raw_payload=data, + _payload=data, ) @@ -172,7 +172,7 @@ def map_resume(payload: Payload) -> ResumeInfo: location=_str(payload, "location") or _str(_mapping(payload, "address_details"), "location"), salary=_int(payload, "salary") or _int(salary_payload, "value", "from"), - raw_payload=payload, + _payload=payload, ) @@ -185,7 +185,7 @@ def map_resumes(payload: object) -> ResumesResult: items=[map_resume(item) for item in _list(data, "resumes", "items", "result")], cursor=_str(meta, "cursor"), total=_int(meta, "total"), - raw_payload=data, + _payload=data, ) @@ -204,7 +204,7 @@ def map_resume_contacts(payload: object) -> ResumeContactInfo: name=_str(data, "name", "fullName"), phone=_str(data, "phone", "phoneNumber"), email=_str(data, "email"), - raw_payload=data, + _payload=data, ) @@ -220,7 +220,7 @@ def map_vacancy(payload: Payload) -> VacancyInfo: title=_str(payload, "title", "name"), status=_str(payload, "status", "state"), url=_str(payload, "url"), - raw_payload=payload, + _payload=payload, ) @@ -242,7 +242,7 @@ def map_vacancies(payload: object) -> VacanciesResult: return VacanciesResult( items=[map_vacancy(item) for item in items], total=_int(meta, "total") or _int(data, "total"), - raw_payload=data, + _payload=data, ) @@ -261,11 +261,11 @@ def map_vacancy_statuses(payload: object) -> VacancyStatusesResult: ), uuid=_str(item, "uuid", "vacancy_uuid"), status=_str(item, "status", "state"), - raw_payload=item, + _payload=item, ) for item in _list(data, "items", "statuses", "vacancies", "result") ], - raw_payload=data, + _payload=data, ) @@ -277,7 +277,7 @@ def map_job_webhook(payload: object) -> JobWebhookInfo: url=_str(data, "url"), is_active=_bool(data, "is_active", "isActive", "active"), version=_str(data, "version"), - raw_payload=data, + _payload=data, ) @@ -287,13 +287,13 @@ def map_job_webhooks(payload: object) -> JobWebhooksResult: if isinstance(payload, list): items_payload = _expect_list(payload) return JobWebhooksResult( - items=[map_job_webhook(item) for item in items_payload], raw_payload={} + items=[map_job_webhook(item) for item in items_payload], _payload={} ) data = _expect_mapping(payload) return JobWebhooksResult( items=[map_job_webhook(item) for item in _list(data, "items", "webhooks", "result")], - raw_payload=data, + _payload=data, ) @@ -310,11 +310,11 @@ def map_job_dictionaries(payload: object) -> JobDictionariesResult: JobDictionaryInfo( id=_str(item, "id"), description=_str(item, "description"), - raw_payload=item, + _payload=item, ) for item in items_payload ], - raw_payload={} if isinstance(payload, list) else _expect_mapping(payload), + _payload={} if isinstance(payload, list) else _expect_mapping(payload), ) @@ -332,9 +332,9 @@ def map_job_dictionary_values(payload: object) -> JobDictionaryValuesResult: id=_int(item, "id") if _int(item, "id") is not None else _str(item, "id"), name=_str(item, "name", "description"), deprecated=_bool(item, "deprecated"), - raw_payload=item, + _payload=item, ) for item in items_payload ], - raw_payload={} if isinstance(payload, list) else _expect_mapping(payload), + _payload={} if isinstance(payload, list) else _expect_mapping(payload), ) diff --git a/avito/jobs/models.py b/avito/jobs/models.py index 2901974..e99a123 100644 --- a/avito/jobs/models.py +++ b/avito/jobs/models.py @@ -5,10 +5,12 @@ from collections.abc import Mapping from dataclasses import dataclass, field +from avito.core.serialization import enable_module_serialization + @dataclass(slots=True, frozen=True) -class JsonRequest: - """Типизированная обертка над JSON payload запроса.""" +class JobsRequest: + """Унифицированный typed request для Jobs API.""" payload: Mapping[str, object] @@ -18,6 +20,18 @@ def to_payload(self) -> dict[str, object]: return dict(self.payload) +@dataclass(slots=True, frozen=True) +class JobsQuery: + """Унифицированный typed query для Jobs API.""" + + params: Mapping[str, object] + + def to_params(self) -> dict[str, object]: + """Сериализует query-параметры запроса.""" + + return dict(self.params) + + @dataclass(slots=True, frozen=True) class JobActionResult: """Результат mutation-операции Jobs API.""" @@ -26,7 +40,7 @@ class JobActionResult: id: str | None = None status: str | None = None message: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -39,7 +53,7 @@ class ApplicationInfo: state: str | None is_viewed: bool | None applicant_name: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -47,7 +61,7 @@ class ApplicationsResult: """Список откликов.""" items: list[ApplicationInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -56,7 +70,7 @@ class ApplicationIdItem: id: str | None updated_at: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -65,7 +79,7 @@ class ApplicationIdsResult: items: list[ApplicationIdItem] cursor: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -74,7 +88,7 @@ class ApplicationState: slug: str | None description: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -82,7 +96,7 @@ class ApplicationStatesResult: """Список возможных статусов откликов.""" items: list[ApplicationState] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -94,7 +108,7 @@ class ResumeInfo: candidate_name: str | None location: str | None salary: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -104,7 +118,7 @@ class ResumesResult: items: list[ResumeInfo] cursor: str | None = None total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -114,7 +128,7 @@ class ResumeContactInfo: name: str | None phone: str | None email: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -126,7 +140,7 @@ class VacancyInfo: title: str | None status: str | None url: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -135,7 +149,7 @@ class VacanciesResult: items: list[VacancyInfo] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -145,7 +159,7 @@ class VacancyStatusInfo: id: str | None uuid: str | None status: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -153,7 +167,7 @@ class VacancyStatusesResult: """Список статусов вакансий.""" items: list[VacancyStatusInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -163,7 +177,7 @@ class JobWebhookInfo: url: str | None is_active: bool | None version: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -171,7 +185,7 @@ class JobWebhooksResult: """Список webhook-подписок.""" items: list[JobWebhookInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -180,7 +194,7 @@ class JobDictionaryInfo: id: str | None description: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -188,7 +202,7 @@ class JobDictionariesResult: """Список доступных словарей.""" items: list[JobDictionaryInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -198,7 +212,7 @@ class JobDictionaryValue: id: int | str | None name: str | None deprecated: bool | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -206,4 +220,7 @@ class JobDictionaryValuesResult: """Список значений словаря.""" items: list[JobDictionaryValue] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) + + +enable_module_serialization(globals()) diff --git a/avito/messages/__init__.py b/avito/messages/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/avito/messages/message.py b/avito/messages/message.py deleted file mode 100644 index 68c5f23..0000000 --- a/avito/messages/message.py +++ /dev/null @@ -1 +0,0 @@ -class Message: ... diff --git a/avito/messenger/domain.py b/avito/messenger/domain.py index c16df43..6c577b4 100644 --- a/avito/messenger/domain.py +++ b/avito/messenger/domain.py @@ -4,7 +4,7 @@ from dataclasses import dataclass -from avito.core import Transport +from avito.core import Transport, ValidationError from avito.messenger.client import MediaClient, MessengerClient, SpecialOffersClient, WebhookClient from avito.messenger.models import ( BlacklistRequest, @@ -76,12 +76,12 @@ def blacklist(self, *, blacklisted_user_id: int) -> MessageActionResult: def _require_user_id(self) -> int: if self.user_id is None: - raise ValueError("Для операции требуется `user_id`.") + raise ValidationError("Для операции требуется `user_id`.") return int(self.user_id) def _require_chat_id(self) -> str: if self.resource_id is None: - raise ValueError("Для операции требуется `chat_id`.") + raise ValidationError("Для операции требуется `chat_id`.") return str(self.resource_id) @@ -134,17 +134,17 @@ def delete( def _require_user_id(self) -> int: if self.user_id is None: - raise ValueError("Для операции требуется `user_id`.") + raise ValidationError("Для операции требуется `user_id`.") return int(self.user_id) def _require_chat_id(self) -> str: if self.resource_id is None: - raise ValueError("Для операции требуется `chat_id`.") + raise ValidationError("Для операции требуется `chat_id`.") return str(self.resource_id) def _require_message_id(self) -> str: if self.resource_id is None: - raise ValueError("Для операции требуется `message_id`.") + raise ValidationError("Для операции требуется `message_id`.") return str(self.resource_id) @@ -192,7 +192,7 @@ def upload_images(self, *, files: dict[str, object]) -> UploadImagesResult: def _require_user_id(self) -> int: if self.user_id is None: - raise ValueError("Для операции требуется `user_id`.") + raise ValidationError("Для операции требуется `user_id`.") return int(self.user_id) @@ -244,7 +244,7 @@ def get_tariff_info(self) -> TariffInfo: def _require_campaign_id(self) -> str: if self.resource_id is None: - raise ValueError("Для операции требуется `campaign_id`.") + raise ValidationError("Для операции требуется `campaign_id`.") return str(self.resource_id) diff --git a/avito/messenger/mappers.py b/avito/messenger/mappers.py index 69467d7..a5bd90e 100644 --- a/avito/messenger/mappers.py +++ b/avito/messenger/mappers.py @@ -91,7 +91,7 @@ def map_chat(payload: object) -> ChatInfo: title=_str(data, "title", "name"), unread_count=_int(data, "unread_count", "unreadCount"), last_message_text=_str(last_message_data, "text", "message"), - raw_payload=data, + _payload=data, ) @@ -102,7 +102,7 @@ def map_chats(payload: object) -> ChatsResult: return ChatsResult( items=[map_chat(item) for item in _list(data, "chats", "items", "result")], total=_int(data, "total", "count"), - raw_payload=data, + _payload=data, ) @@ -118,7 +118,7 @@ def map_message(payload: object) -> MessageInfo: created_at=_str(data, "created_at", "createdAt"), direction=_str(data, "direction"), type=_str(data, "type"), - raw_payload=data, + _payload=data, ) @@ -129,7 +129,7 @@ def map_messages(payload: object) -> MessagesResult: return MessagesResult( items=[map_message(item) for item in _list(data, "messages", "items", "result")], total=_int(data, "total", "count"), - raw_payload=data, + _payload=data, ) @@ -141,7 +141,7 @@ def map_message_action(payload: object) -> MessageActionResult: success=bool(data.get("success", True)), message_id=_str(data, "message_id", "messageId", "id"), status=_str(data, "status", "message"), - raw_payload=data, + _payload=data, ) @@ -156,11 +156,11 @@ def map_voice_files(payload: object) -> VoiceFilesResult: url=_str(item, "url"), duration=_int(item, "duration"), transcript=_str(item, "transcript", "text"), - raw_payload=item, + _payload=item, ) for item in _list(data, "voice_files", "items", "result") ], - raw_payload=data, + _payload=data, ) @@ -173,11 +173,11 @@ def map_upload_images(payload: object) -> UploadImagesResult: UploadImageResult( image_id=_str(item, "image_id", "imageId", "id"), url=_str(item, "url"), - raw_payload=item, + _payload=item, ) for item in _list(data, "images", "items", "result") ], - raw_payload=data, + _payload=data, ) @@ -191,11 +191,11 @@ def map_subscriptions(payload: object) -> SubscriptionsResult: url=_str(item, "url"), version=_str(item, "version"), status=_str(item, "status"), - raw_payload=item, + _payload=item, ) for item in _list(data, "subscriptions", "items", "result") ], - raw_payload=data, + _payload=data, ) @@ -206,7 +206,7 @@ def map_webhook_action(payload: object) -> WebhookActionResult: return WebhookActionResult( success=bool(data.get("success", True)), status=_str(data, "status", "message"), - raw_payload=data, + _payload=data, ) @@ -220,11 +220,11 @@ def map_available_special_offers(payload: object) -> SpecialOfferAvailableResult item_id=_int(item, "item_id", "itemId", "id"), title=_str(item, "title"), is_available=_bool(item, "is_available", "isAvailable", "available"), - raw_payload=item, + _payload=item, ) for item in _list(data, "items", "result") ], - raw_payload=data, + _payload=data, ) @@ -235,7 +235,7 @@ def map_multi_create_result(payload: object) -> MultiCreateSpecialOfferResult: return MultiCreateSpecialOfferResult( campaign_id=_str(data, "campaign_id", "campaignId", "id"), status=_str(data, "status"), - raw_payload=data, + _payload=data, ) @@ -248,7 +248,7 @@ def map_special_offer_stats(payload: object) -> SpecialOfferStatsResult: sent_count=_int(data, "sent_count", "sentCount"), delivered_count=_int(data, "delivered_count", "deliveredCount"), read_count=_int(data, "read_count", "readCount"), - raw_payload=data, + _payload=data, ) @@ -260,7 +260,7 @@ def map_tariff_info(payload: object) -> TariffInfo: price=_float(data, "price", "amount"), currency=_str(data, "currency"), daily_limit=_int(data, "daily_limit", "dailyLimit", "limit"), - raw_payload=data, + _payload=data, ) diff --git a/avito/messenger/models.py b/avito/messenger/models.py index 6e4e51d..c3291af 100644 --- a/avito/messenger/models.py +++ b/avito/messenger/models.py @@ -5,6 +5,8 @@ from collections.abc import Mapping from dataclasses import dataclass, field +from avito.core.serialization import enable_module_serialization + @dataclass(slots=True, frozen=True) class ChatInfo: @@ -15,7 +17,7 @@ class ChatInfo: title: str | None unread_count: int | None last_message_text: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -24,7 +26,7 @@ class ChatsResult: items: list[ChatInfo] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -72,7 +74,7 @@ class MessageInfo: created_at: str | None direction: str | None type: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -81,7 +83,7 @@ class MessagesResult: items: list[MessageInfo] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -91,7 +93,7 @@ class MessageActionResult: success: bool message_id: str | None = None status: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -102,7 +104,7 @@ class VoiceFile: url: str | None duration: int | None transcript: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -110,7 +112,7 @@ class VoiceFilesResult: """Список голосовых сообщений.""" items: list[VoiceFile] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -119,7 +121,7 @@ class UploadImageResult: image_id: str | None url: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -127,7 +129,7 @@ class UploadImagesResult: """Список загруженных изображений.""" items: list[UploadImageResult] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -137,7 +139,7 @@ class SubscriptionInfo: url: str | None version: str | None status: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -145,7 +147,7 @@ class SubscriptionsResult: """Список webhook-подписок.""" items: list[SubscriptionInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -183,7 +185,7 @@ class WebhookActionResult: success: bool status: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -217,7 +219,7 @@ class SpecialOfferAvailableItem: item_id: int | None title: str | None is_available: bool | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -225,7 +227,7 @@ class SpecialOfferAvailableResult: """Результат получения доступных объявлений.""" items: list[SpecialOfferAvailableItem] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -256,7 +258,7 @@ class MultiCreateSpecialOfferResult: campaign_id: str | None status: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -291,7 +293,7 @@ class SpecialOfferStatsResult: sent_count: int | None delivered_count: int | None read_count: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -301,7 +303,7 @@ class TariffInfo: price: float | None currency: str | None daily_limit: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) __all__ = ( @@ -332,3 +334,5 @@ class TariffInfo: "VoiceFilesResult", "WebhookActionResult", ) + +enable_module_serialization(globals()) diff --git a/avito/orders/client.py b/avito/orders/client.py index 63bf6e5..9dc23d5 100644 --- a/avito/orders/client.py +++ b/avito/orders/client.py @@ -21,10 +21,10 @@ DeliveryEntityResult, DeliverySortingCentersResult, DeliveryTaskInfo, - JsonRequest, LabelPdfResult, LabelTaskResult, OrderActionResult, + OrdersRequest, OrdersResult, StockInfoResult, StockUpdateResult, @@ -45,31 +45,31 @@ def list_orders(self) -> OrdersResult: ) return map_orders(payload) - def update_markings(self, request: JsonRequest) -> OrderActionResult: + def update_markings(self, request: OrdersRequest) -> OrderActionResult: return self._post_action("/order-management/1/markings", "orders.update_markings", request) - def accept_return_order(self, request: JsonRequest) -> OrderActionResult: + def accept_return_order(self, request: OrdersRequest) -> OrderActionResult: return self._post_action( "/order-management/1/order/acceptReturnOrder", "orders.accept_return_order", request, ) - def apply_transition(self, request: JsonRequest) -> OrderActionResult: + def apply_transition(self, request: OrdersRequest) -> OrderActionResult: return self._post_action( "/order-management/1/order/applyTransition", "orders.apply_transition", request, ) - def check_confirmation_code(self, request: JsonRequest) -> OrderActionResult: + def check_confirmation_code(self, request: OrdersRequest) -> OrderActionResult: return self._post_action( "/order-management/1/order/checkConfirmationCode", "orders.check_confirmation_code", request, ) - def set_cnc_details(self, request: JsonRequest) -> OrderActionResult: + def set_cnc_details(self, request: OrdersRequest) -> OrderActionResult: return self._post_action( "/order-management/1/order/cncSetDetails", "orders.set_cnc_details", @@ -84,21 +84,21 @@ def get_courier_delivery_range(self) -> CourierRangesResult: ) return map_courier_ranges(payload) - def set_courier_delivery_range(self, request: JsonRequest) -> OrderActionResult: + def set_courier_delivery_range(self, request: OrdersRequest) -> OrderActionResult: return self._post_action( "/order-management/1/order/setCourierDeliveryRange", "orders.set_courier_delivery_range", request, ) - def set_tracking_number(self, request: JsonRequest) -> OrderActionResult: + def set_tracking_number(self, request: OrdersRequest) -> OrderActionResult: return self._post_action( "/order-management/1/order/setTrackingNumber", "orders.set_tracking_number", request, ) - def _post_action(self, path: str, operation: str, request: JsonRequest) -> OrderActionResult: + def _post_action(self, path: str, operation: str, request: OrdersRequest) -> OrderActionResult: payload = self.transport.request_json( "POST", path, @@ -114,10 +114,10 @@ class LabelsClient: transport: Transport - def create_generate_labels(self, request: JsonRequest) -> LabelTaskResult: + def create_generate_labels(self, request: OrdersRequest) -> LabelTaskResult: return self._create("/order-management/1/orders/labels", "orders.labels.create", request) - def create_generate_labels_extended(self, request: JsonRequest) -> LabelTaskResult: + def create_generate_labels_extended(self, request: OrdersRequest) -> LabelTaskResult: return self._create( "/order-management/1/orders/labels/extended", "orders.labels.create_extended", @@ -131,7 +131,7 @@ def get_download_label(self, *, task_id: str) -> LabelPdfResult: ) return LabelPdfResult(binary=binary) - def _create(self, path: str, operation: str, request: JsonRequest) -> LabelTaskResult: + def _create(self, path: str, operation: str, request: OrdersRequest) -> LabelTaskResult: payload = self.transport.request_json( "POST", path, @@ -147,28 +147,28 @@ class DeliveryClient: transport: Transport - def create_announcement(self, request: JsonRequest) -> DeliveryEntityResult: + def create_announcement(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post("/createAnnouncement", "orders.delivery.create_announcement", request) - def cancel_announcement(self, request: JsonRequest) -> DeliveryEntityResult: + def cancel_announcement(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post("/cancelAnnouncement", "orders.delivery.cancel_announcement", request) - def create_parcel(self, request: JsonRequest) -> DeliveryEntityResult: + def create_parcel(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post("/createParcel", "orders.delivery.create_parcel", request) - def change_parcel_result(self, request: JsonRequest) -> DeliveryEntityResult: + def change_parcel_result(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post( "/delivery/order/changeParcelResult", "orders.delivery.change_parcel_result", request, ) - def update_change_parcels(self, request: JsonRequest) -> DeliveryEntityResult: + def update_change_parcels(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post( "/sandbox/changeParcels", "orders.delivery.update_change_parcels", request ) - def _post(self, path: str, operation: str, request: JsonRequest) -> DeliveryEntityResult: + def _post(self, path: str, operation: str, request: OrdersRequest) -> DeliveryEntityResult: payload = self.transport.request_json( "POST", path, @@ -184,51 +184,51 @@ class SandboxDeliveryClient: transport: Transport - def create_announcement(self, request: JsonRequest) -> DeliveryEntityResult: + def create_announcement(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/announcements/create", "orders.sandbox.create_announcement", request ) - def track_announcement(self, request: JsonRequest) -> DeliveryEntityResult: + def track_announcement(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/announcements/track", "orders.sandbox.track_announcement", request ) - def update_custom_area_schedule(self, request: JsonRequest) -> DeliveryEntityResult: + def update_custom_area_schedule(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/areas/custom-schedule", "orders.sandbox.update_custom_area_schedule", request, ) - def cancel_parcel(self, request: JsonRequest) -> DeliveryEntityResult: + def cancel_parcel(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post("/delivery-sandbox/cancelParcel", "orders.sandbox.cancel_parcel", request) - def check_confirmation_code(self, request: JsonRequest) -> DeliveryEntityResult: + def check_confirmation_code(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/order/checkConfirmationCode", "orders.sandbox.check_confirmation_code", request, ) - def set_order_properties(self, request: JsonRequest) -> DeliveryEntityResult: + def set_order_properties(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/order/properties", "orders.sandbox.set_order_properties", request, ) - def set_order_real_address(self, request: JsonRequest) -> DeliveryEntityResult: + def set_order_real_address(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/order/realAddress", "orders.sandbox.set_order_real_address", request, ) - def tracking(self, request: JsonRequest) -> DeliveryEntityResult: + def tracking(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post("/delivery-sandbox/order/tracking", "orders.sandbox.tracking", request) - def prohibit_order_acceptance(self, request: JsonRequest) -> DeliveryEntityResult: + def prohibit_order_acceptance(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/prohibitOrderAcceptance", "orders.sandbox.prohibit_order_acceptance", @@ -243,14 +243,14 @@ def list_sorting_center(self) -> DeliverySortingCentersResult: ) return map_sorting_centers(payload) - def add_sorting_center(self, request: JsonRequest) -> DeliveryEntityResult: + def add_sorting_center(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/tariffs/sorting-center", "orders.sandbox.add_sorting_center", request, ) - def add_areas(self, *, tariff_id: str, request: JsonRequest) -> DeliveryEntityResult: + def add_areas(self, *, tariff_id: str, request: OrdersRequest) -> DeliveryEntityResult: return self._post( f"/delivery-sandbox/tariffs/{tariff_id}/areas", "orders.sandbox.add_areas", @@ -258,7 +258,7 @@ def add_areas(self, *, tariff_id: str, request: JsonRequest) -> DeliveryEntityRe ) def add_tags_to_sorting_center( - self, *, tariff_id: str, request: JsonRequest + self, *, tariff_id: str, request: OrdersRequest ) -> DeliveryEntityResult: return self._post( f"/delivery-sandbox/tariffs/{tariff_id}/tagged-sorting-centers", @@ -266,81 +266,81 @@ def add_tags_to_sorting_center( request, ) - def add_terminals(self, *, tariff_id: str, request: JsonRequest) -> DeliveryEntityResult: + def add_terminals(self, *, tariff_id: str, request: OrdersRequest) -> DeliveryEntityResult: return self._post( f"/delivery-sandbox/tariffs/{tariff_id}/terminals", "orders.sandbox.add_terminals", request, ) - def update_terms(self, *, tariff_id: str, request: JsonRequest) -> DeliveryEntityResult: + def update_terms(self, *, tariff_id: str, request: OrdersRequest) -> DeliveryEntityResult: return self._post( f"/delivery-sandbox/tariffs/{tariff_id}/terms", "orders.sandbox.update_terms", request, ) - def add_tariff_v2(self, request: JsonRequest) -> DeliveryEntityResult: + def add_tariff_v2(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post("/delivery-sandbox/tariffsV2", "orders.sandbox.add_tariff_v2", request) - def v1_cancel_announcement(self, request: JsonRequest) -> DeliveryEntityResult: + def v1_cancel_announcement(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/cancelAnnouncement", "orders.sandbox.v1_cancel_announcement", request, ) - def v1_cancel_parcel(self, request: JsonRequest) -> DeliveryEntityResult: + def v1_cancel_parcel(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/cancelParcel", "orders.sandbox.v1_cancel_parcel", request ) - def v1_change_parcel(self, request: JsonRequest) -> DeliveryEntityResult: + def v1_change_parcel(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/changeParcel", "orders.sandbox.v1_change_parcel", request ) - def v1_create_announcement(self, request: JsonRequest) -> DeliveryEntityResult: + def v1_create_announcement(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/createAnnouncement", "orders.sandbox.v1_create_announcement", request, ) - def v1_get_announcement_event(self, request: JsonRequest) -> DeliveryEntityResult: + def v1_get_announcement_event(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/getAnnouncementEvent", "orders.sandbox.v1_get_announcement_event", request, ) - def v1_get_change_parcel_info(self, request: JsonRequest) -> DeliveryEntityResult: + def v1_get_change_parcel_info(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/getChangeParcelInfo", "orders.sandbox.v1_get_change_parcel_info", request, ) - def v1_get_parcel_info(self, request: JsonRequest) -> DeliveryEntityResult: + def v1_get_parcel_info(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/getParcelInfo", "orders.sandbox.v1_get_parcel_info", request, ) - def v1_get_registered_parcel_id(self, request: JsonRequest) -> DeliveryEntityResult: + def v1_get_registered_parcel_id(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/getRegisteredParcelID", "orders.sandbox.v1_get_registered_parcel_id", request, ) - def create_parcel_v2(self, request: JsonRequest) -> DeliveryEntityResult: + def create_parcel_v2(self, request: OrdersRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v2/createParcel", "orders.sandbox.create_parcel_v2", request ) - def _post(self, path: str, operation: str, request: JsonRequest) -> DeliveryEntityResult: + def _post(self, path: str, operation: str, request: OrdersRequest) -> DeliveryEntityResult: payload = self.transport.request_json( "POST", path, @@ -371,7 +371,7 @@ class StockManagementClient: transport: Transport - def get_info(self, request: JsonRequest) -> StockInfoResult: + def get_info(self, request: OrdersRequest) -> StockInfoResult: payload = self.transport.request_json( "POST", "/stock-management/1/info", @@ -380,7 +380,7 @@ def get_info(self, request: JsonRequest) -> StockInfoResult: ) return map_stock_info(payload) - def update_stocks(self, request: JsonRequest) -> StockUpdateResult: + def update_stocks(self, request: OrdersRequest) -> StockUpdateResult: payload = self.transport.request_json( "PUT", "/stock-management/1/stocks", diff --git a/avito/orders/domain.py b/avito/orders/domain.py index eeac526..364696f 100644 --- a/avito/orders/domain.py +++ b/avito/orders/domain.py @@ -2,10 +2,9 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass -from avito.core import Transport +from avito.core import Transport, ValidationError from avito.orders.client import ( DeliveryClient, DeliveryTasksClient, @@ -19,10 +18,10 @@ DeliveryEntityResult, DeliverySortingCentersResult, DeliveryTaskInfo, - JsonRequest, LabelPdfResult, LabelTaskResult, OrderActionResult, + OrdersRequest, OrdersResult, StockInfoResult, StockUpdateResult, @@ -46,29 +45,29 @@ class Order(DomainObject): def list(self) -> OrdersResult: return OrdersClient(self.transport).list_orders() - def update_markings(self, *, payload: Mapping[str, object]) -> OrderActionResult: - return OrdersClient(self.transport).update_markings(JsonRequest(payload)) + def update_markings(self, *, request: OrdersRequest) -> OrderActionResult: + return OrdersClient(self.transport).update_markings(request) - def accept_return_order(self, *, payload: Mapping[str, object]) -> OrderActionResult: - return OrdersClient(self.transport).accept_return_order(JsonRequest(payload)) + def accept_return_order(self, *, request: OrdersRequest) -> OrderActionResult: + return OrdersClient(self.transport).accept_return_order(request) - def apply(self, *, payload: Mapping[str, object]) -> OrderActionResult: - return OrdersClient(self.transport).apply_transition(JsonRequest(payload)) + def apply(self, *, request: OrdersRequest) -> OrderActionResult: + return OrdersClient(self.transport).apply_transition(request) - def check_confirmation_code(self, *, payload: Mapping[str, object]) -> OrderActionResult: - return OrdersClient(self.transport).check_confirmation_code(JsonRequest(payload)) + def check_confirmation_code(self, *, request: OrdersRequest) -> OrderActionResult: + return OrdersClient(self.transport).check_confirmation_code(request) - def set_cnc_details(self, *, payload: Mapping[str, object]) -> OrderActionResult: - return OrdersClient(self.transport).set_cnc_details(JsonRequest(payload)) + def set_cnc_details(self, *, request: OrdersRequest) -> OrderActionResult: + return OrdersClient(self.transport).set_cnc_details(request) def get_courier_delivery_range(self) -> CourierRangesResult: return OrdersClient(self.transport).get_courier_delivery_range() - def set_courier_delivery_range(self, *, payload: Mapping[str, object]) -> OrderActionResult: - return OrdersClient(self.transport).set_courier_delivery_range(JsonRequest(payload)) + def set_courier_delivery_range(self, *, request: OrdersRequest) -> OrderActionResult: + return OrdersClient(self.transport).set_courier_delivery_range(request) - def update_tracking_number(self, *, payload: Mapping[str, object]) -> OrderActionResult: - return OrdersClient(self.transport).set_tracking_number(JsonRequest(payload)) + def update_tracking_number(self, *, request: OrdersRequest) -> OrderActionResult: + return OrdersClient(self.transport).set_tracking_number(request) @dataclass(slots=True, frozen=True) @@ -78,9 +77,8 @@ class OrderLabel(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def create(self, *, payload: Mapping[str, object], extended: bool = False) -> LabelTaskResult: + def create(self, *, request: OrdersRequest, extended: bool = False) -> LabelTaskResult: client = LabelsClient(self.transport) - request = JsonRequest(payload) if extended: return client.create_generate_labels_extended(request) return client.create_generate_labels(request) @@ -91,7 +89,7 @@ def download(self, *, task_id: str | None = None) -> LabelPdfResult: def _require_task_id(self) -> str: if self.resource_id is None: - raise ValueError("Для операции требуется `task_id`.") + raise ValidationError("Для операции требуется `task_id`.") return str(self.resource_id) @@ -102,20 +100,20 @@ class DeliveryOrder(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def create_announcement(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return DeliveryClient(self.transport).create_announcement(JsonRequest(payload)) + def create_announcement(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return DeliveryClient(self.transport).create_announcement(request) - def delete(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return DeliveryClient(self.transport).cancel_announcement(JsonRequest(payload)) + def delete(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return DeliveryClient(self.transport).cancel_announcement(request) - def create(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return DeliveryClient(self.transport).create_parcel(JsonRequest(payload)) + def create(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return DeliveryClient(self.transport).create_parcel(request) - def update_change_parcels(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return DeliveryClient(self.transport).update_change_parcels(JsonRequest(payload)) + def update_change_parcels(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return DeliveryClient(self.transport).update_change_parcels(request) - def create_change_parcel_result(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return DeliveryClient(self.transport).change_parcel_result(JsonRequest(payload)) + def create_change_parcel_result(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return DeliveryClient(self.transport).change_parcel_result(request) @dataclass(slots=True, frozen=True) @@ -125,105 +123,101 @@ class SandboxDelivery(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def create_announcement(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).create_announcement(JsonRequest(payload)) + def create_announcement(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).create_announcement(request) - def track_announcement(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).track_announcement(JsonRequest(payload)) + def track_announcement(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).track_announcement(request) - def update_custom_area_schedule(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).update_custom_area_schedule( - JsonRequest(payload) - ) + def update_custom_area_schedule(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).update_custom_area_schedule(request) - def cancel_parcel(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).cancel_parcel(JsonRequest(payload)) + def cancel_parcel(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).cancel_parcel(request) - def check_confirmation_code(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).check_confirmation_code(JsonRequest(payload)) + def check_confirmation_code(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).check_confirmation_code(request) - def set_order_properties(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).set_order_properties(JsonRequest(payload)) + def set_order_properties(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).set_order_properties(request) - def set_order_real_address(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).set_order_real_address(JsonRequest(payload)) + def set_order_real_address(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).set_order_real_address(request) - def tracking(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).tracking(JsonRequest(payload)) + def tracking(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).tracking(request) - def prohibit_order_acceptance(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).prohibit_order_acceptance(JsonRequest(payload)) + def prohibit_order_acceptance(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).prohibit_order_acceptance(request) def list_sorting_center(self) -> DeliverySortingCentersResult: return SandboxDeliveryClient(self.transport).list_sorting_center() - def add_sorting_center(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).add_sorting_center(JsonRequest(payload)) + def add_sorting_center(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).add_sorting_center(request) - def add_areas(self, *, tariff_id: str, payload: Mapping[str, object]) -> DeliveryEntityResult: + def add_areas(self, *, tariff_id: str, request: OrdersRequest) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).add_areas( - tariff_id=tariff_id, request=JsonRequest(payload) + tariff_id=tariff_id, request=request ) def add_tags_to_sorting_center( - self, *, tariff_id: str, payload: Mapping[str, object] + self, *, tariff_id: str, request: OrdersRequest ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).add_tags_to_sorting_center( tariff_id=tariff_id, - request=JsonRequest(payload), + request=request, ) def add_terminals( - self, *, tariff_id: str, payload: Mapping[str, object] + self, *, tariff_id: str, request: OrdersRequest ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).add_terminals( - tariff_id=tariff_id, request=JsonRequest(payload) + tariff_id=tariff_id, request=request ) def update_terms( - self, *, tariff_id: str, payload: Mapping[str, object] + self, *, tariff_id: str, request: OrdersRequest ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).update_terms( - tariff_id=tariff_id, request=JsonRequest(payload) + tariff_id=tariff_id, request=request ) - def add_tariff_v2(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).add_tariff_v2(JsonRequest(payload)) + def add_tariff_v2(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).add_tariff_v2(request) - def create_parcel(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).create_parcel_v2(JsonRequest(payload)) + def create_parcel(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).create_parcel_v2(request) - def legacy_cancel_announcement(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_cancel_announcement(JsonRequest(payload)) + def cancel_announcement_v1(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).v1_cancel_announcement(request) - def legacy_cancel_parcel(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_cancel_parcel(JsonRequest(payload)) + def cancel_parcel_v1(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).v1_cancel_parcel(request) - def legacy_change_parcel(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_change_parcel(JsonRequest(payload)) + def change_parcel_v1(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).v1_change_parcel(request) - def legacy_create_announcement(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_create_announcement(JsonRequest(payload)) + def create_announcement_v1(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).v1_create_announcement(request) - def legacy_get_announcement_event( - self, *, payload: Mapping[str, object] + def get_announcement_event_v1( + self, *, request: OrdersRequest ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_get_announcement_event(JsonRequest(payload)) + return SandboxDeliveryClient(self.transport).v1_get_announcement_event(request) - def legacy_get_change_parcel_info( - self, *, payload: Mapping[str, object] + def get_change_parcel_info_v1( + self, *, request: OrdersRequest ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_get_change_parcel_info(JsonRequest(payload)) + return SandboxDeliveryClient(self.transport).v1_get_change_parcel_info(request) - def legacy_get_parcel_info(self, *, payload: Mapping[str, object]) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_get_parcel_info(JsonRequest(payload)) + def get_parcel_info_v1(self, *, request: OrdersRequest) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).v1_get_parcel_info(request) - def legacy_get_registered_parcel_id( - self, *, payload: Mapping[str, object] + def get_registered_parcel_id_v1( + self, *, request: OrdersRequest ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_get_registered_parcel_id( - JsonRequest(payload) - ) + return SandboxDeliveryClient(self.transport).v1_get_registered_parcel_id(request) @dataclass(slots=True, frozen=True) @@ -239,7 +233,7 @@ def get(self, *, task_id: str | None = None) -> DeliveryTaskInfo: def _require_task_id(self) -> str: if self.resource_id is None: - raise ValueError("Для операции требуется `task_id`.") + raise ValidationError("Для операции требуется `task_id`.") return str(self.resource_id) @@ -250,11 +244,11 @@ class Stock(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def get(self, *, payload: Mapping[str, object]) -> StockInfoResult: - return StockManagementClient(self.transport).get_info(JsonRequest(payload)) + def get(self, *, request: OrdersRequest) -> StockInfoResult: + return StockManagementClient(self.transport).get_info(request) - def update(self, *, payload: Mapping[str, object]) -> StockUpdateResult: - return StockManagementClient(self.transport).update_stocks(JsonRequest(payload)) + def update(self, *, request: OrdersRequest) -> StockUpdateResult: + return StockManagementClient(self.transport).update_stocks(request) __all__ = ( diff --git a/avito/orders/mappers.py b/avito/orders/mappers.py index ada296e..c97443c 100644 --- a/avito/orders/mappers.py +++ b/avito/orders/mappers.py @@ -86,12 +86,12 @@ def map_orders(payload: object) -> OrdersResult: created_at=_str(item, "created", "created_at", "createdAt"), buyer_name=_str(_mapping(item, "buyerInfo"), "fullName"), total_price=_int(item, "totalPrice", "price"), - raw_payload=item, + _payload=item, ) for item in _list(data, "orders", "items", "result") ], total=_int(data, "total", "count"), - raw_payload=data, + _payload=data, ) @@ -106,7 +106,7 @@ def map_order_action(payload: object) -> OrderActionResult: order_id=_str(source, "orderId", "order_id", "id"), status=_str(source, "status"), message=_str(source, "message"), - raw_payload=data, + _payload=data, ) @@ -123,12 +123,12 @@ def map_courier_ranges(payload: object) -> CourierRangesResult: date=_str(item, "date"), start_at=_str(item, "startAt", "startDate"), end_at=_str(item, "endAt", "endDate"), - raw_payload=item, + _payload=item, ) for item in _list(source, "timeIntervals", "intervals", "items", "result") ], address=_str(source, "address"), - raw_payload=data, + _payload=data, ) @@ -143,7 +143,7 @@ def map_label_task(payload: object) -> LabelTaskResult: return LabelTaskResult( task_id=task_id or (str(task_int) if task_int is not None else None), status=_str(source, "status"), - raw_payload=data, + _payload=data, ) @@ -162,7 +162,7 @@ def map_delivery_entity(payload: object) -> DeliveryEntityResult: parcel_id=_str(source, "parcelId", "parcelID"), status=_str(source, "status"), message=_str(_mapping(data, "error"), "message") or _str(source, "message"), - raw_payload=data, + _payload=data, ) @@ -178,11 +178,11 @@ def map_sorting_centers(payload: object) -> DeliverySortingCentersResult: sorting_center_id=_str(item, "id", "sortingCenterId", "sorting_center_id"), name=_str(item, "name"), city=_str(item, "city"), - raw_payload=item, + _payload=item, ) for item in _list(source, "sortingCenters", "items", "result") ], - raw_payload=data, + _payload=data, ) @@ -198,7 +198,7 @@ def map_delivery_task(payload: object) -> DeliveryTaskInfo: task_id=task_id or (str(task_int) if task_int is not None else None), status=_str(source, "status"), error=_str(_mapping(data, "error"), "message") or _str(source, "error"), - raw_payload=data, + _payload=data, ) @@ -214,11 +214,11 @@ def map_stock_info(payload: object) -> StockInfoResult: is_multiple=_bool(item, "is_multiple", "isMultiple"), is_unlimited=_bool(item, "is_unlimited", "isUnlimited"), is_out_of_stock=_bool(item, "is_out_of_stock", "isOutOfStock"), - raw_payload=item, + _payload=item, ) for item in _list(data, "stocks", "items", "result") ], - raw_payload=data, + _payload=data, ) @@ -233,11 +233,11 @@ def map_stock_update(payload: object) -> StockUpdateResult: external_id=_str(item, "external_id", "externalId"), success=bool(item.get("success", True)), errors=_extract_errors(item), - raw_payload=item, + _payload=item, ) for item in _list(data, "stocks", "items", "result") ], - raw_payload=data, + _payload=data, ) diff --git a/avito/orders/models.py b/avito/orders/models.py index 32c567e..b86f51b 100644 --- a/avito/orders/models.py +++ b/avito/orders/models.py @@ -2,15 +2,18 @@ from __future__ import annotations +from base64 import b64encode from collections.abc import Mapping from dataclasses import dataclass, field +from typing import Any from avito.core import BinaryResponse +from avito.core.serialization import enable_module_serialization @dataclass(slots=True, frozen=True) -class JsonRequest: - """Типизированная обертка над JSON payload запроса.""" +class OrdersRequest: + """Унифицированный typed request для Orders API.""" payload: Mapping[str, object] @@ -29,7 +32,7 @@ class OrderSummary: created_at: str | None buyer_name: str | None total_price: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -38,7 +41,7 @@ class OrdersResult: items: list[OrderSummary] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -49,7 +52,7 @@ class OrderActionResult: order_id: str | None = None status: str | None = None message: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -60,7 +63,7 @@ class CourierRange: date: str | None start_at: str | None end_at: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -69,7 +72,7 @@ class CourierRangesResult: items: list[CourierRange] address: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -78,7 +81,7 @@ class LabelTaskResult: task_id: str | None status: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -93,6 +96,18 @@ def filename(self) -> str | None: return self.binary.filename + def to_dict(self) -> dict[str, Any]: + """Сериализует бинарный результат без transport-объекта.""" + + return { + "filename": self.binary.filename, + "content_type": self.binary.content_type, + "content_base64": b64encode(self.binary.content).decode("ascii"), + } + + def model_dump(self) -> dict[str, Any]: + return self.to_dict() + @dataclass(slots=True, frozen=True) class DeliveryEntityResult: @@ -104,7 +119,7 @@ class DeliveryEntityResult: parcel_id: str | None = None status: str | None = None message: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -114,7 +129,7 @@ class DeliverySortingCenter: sorting_center_id: str | None name: str | None city: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -122,7 +137,7 @@ class DeliverySortingCentersResult: """Список сортировочных центров доставки.""" items: list[DeliverySortingCenter] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -132,7 +147,7 @@ class DeliveryTaskInfo: task_id: str | None status: str | None error: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -144,7 +159,7 @@ class StockInfo: is_multiple: bool | None is_unlimited: bool | None is_out_of_stock: bool | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -152,7 +167,7 @@ class StockInfoResult: """Список текущих остатков.""" items: list[StockInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -163,7 +178,7 @@ class StockUpdateItem: external_id: str | None success: bool errors: list[str] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -171,4 +186,7 @@ class StockUpdateResult: """Результат изменения остатков.""" items: list[StockUpdateItem] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) + + +enable_module_serialization(globals()) diff --git a/avito/promotion/__init__.py b/avito/promotion/__init__.py index 1cd482b..ae50f92 100644 --- a/avito/promotion/__init__.py +++ b/avito/promotion/__init__.py @@ -12,9 +12,13 @@ from avito.promotion.models import ( AutostrategyBudget, AutostrategyStat, + BbipBudgetOption, + BbipDurationRange, + BbipForecast, BbipForecastRequestItem, BbipForecastsResult, BbipOrderItem, + BbipSuggest, BbipSuggestsResult, CampaignActionResult, CampaignInfo, @@ -22,23 +26,43 @@ CpaAuctionBidsResult, CreateItemBid, PromotionActionResult, + PromotionForecast, + PromotionOrderError, PromotionOrdersResult, - PromotionOrderStatusesResult, + PromotionOrderStatusItem, + PromotionOrderStatusResult, + PromotionService, PromotionServiceDictionary, PromotionServicesResult, - TargetActionPromotionsResult, + PromotionServiceType, + TargetActionAutoBids, + TargetActionAutoPromotion, + TargetActionBid, + TargetActionBudget, + TargetActionGetBidsResult, + TargetActionManualBids, + TargetActionManualPromotion, + TargetActionPromotion, + TargetActionPromotionsByItemIdsResult, TrxCommissionsResult, TrxPromotionApplyItem, ) +from avito.promotion.models import ( + PromotionOrder as PromotionOrderModel, +) __all__ = ( "AutostrategyBudget", "AutostrategyCampaign", "AutostrategyStat", + "BbipBudgetOption", + "BbipDurationRange", + "BbipForecast", "BbipForecastRequestItem", "BbipForecastsResult", "BbipOrderItem", "BbipPromotion", + "BbipSuggest", "BbipSuggestsResult", "CampaignActionResult", "CampaignInfo", @@ -48,13 +72,27 @@ "CreateItemBid", "DomainObject", "PromotionActionResult", + "PromotionForecast", "PromotionOrder", - "PromotionOrderStatusesResult", + "PromotionOrderModel", + "PromotionOrderError", + "PromotionOrderStatusItem", + "PromotionOrderStatusResult", "PromotionOrdersResult", + "PromotionService", "PromotionServiceDictionary", + "PromotionServiceType", "PromotionServicesResult", "TargetActionPricing", - "TargetActionPromotionsResult", + "TargetActionAutoBids", + "TargetActionAutoPromotion", + "TargetActionBid", + "TargetActionBudget", + "TargetActionGetBidsResult", + "TargetActionManualBids", + "TargetActionManualPromotion", + "TargetActionPromotion", + "TargetActionPromotionsByItemIdsResult", "TrxCommissionsResult", "TrxPromotion", "TrxPromotionApplyItem", diff --git a/avito/promotion/client.py b/avito/promotion/client.py index 440eac2..3d653d9 100644 --- a/avito/promotion/client.py +++ b/avito/promotion/client.py @@ -2,9 +2,11 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from avito.core import RequestContext, Transport +from avito.core.mapping import request_public_model from avito.promotion.mappers import ( map_autostrategy_budget, map_autostrategy_stat, @@ -15,11 +17,12 @@ map_campaigns, map_cpa_auction_bids, map_promotion_action, - map_promotion_order_statuses, + map_promotion_order_status, map_promotion_orders, map_promotion_service_dictionary, map_promotion_services, - map_target_action_promotions, + map_target_action_get_bids_out, + map_target_action_get_promotions_by_item_ids_out, map_trx_commissions, ) from avito.promotion.models import ( @@ -49,11 +52,12 @@ ListPromotionServicesRequest, PromotionActionResult, PromotionOrdersResult, - PromotionOrderStatusesResult, + PromotionOrderStatusResult, PromotionServiceDictionary, PromotionServicesResult, StopAutostrategyCampaignRequest, - TargetActionPromotionsResult, + TargetActionGetBidsResult, + TargetActionPromotionsByItemIdsResult, TrxCommissionsResult, UpdateAutoBidRequest, UpdateAutostrategyCampaignRequest, @@ -70,47 +74,51 @@ class PromotionClient: def get_service_dictionary(self) -> PromotionServiceDictionary: """Получает словарь услуг продвижения.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/promotion/v1/items/services/dict", context=RequestContext("promotion.get_service_dictionary", allow_retry=True), + mapper=map_promotion_service_dictionary, ) - return map_promotion_service_dictionary(payload) def list_services(self, request: ListPromotionServicesRequest) -> PromotionServicesResult: """Получает список услуг продвижения по объявлениям.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/promotion/v1/items/services/get", context=RequestContext("promotion.list_services", allow_retry=True), + mapper=map_promotion_services, json_body=request.to_payload(), ) - return map_promotion_services(payload) def list_orders(self, request: ListPromotionOrdersRequest) -> PromotionOrdersResult: """Получает список заявок на продвижение.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/promotion/v1/items/services/orders/get", context=RequestContext("promotion.list_orders", allow_retry=True), + mapper=map_promotion_orders, json_body=request.to_payload(), ) - return map_promotion_orders(payload) def get_order_status( self, request: GetPromotionOrderStatusRequest - ) -> PromotionOrderStatusesResult: + ) -> PromotionOrderStatusResult: """Получает статусы заявок на продвижение.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/promotion/v1/items/services/orders/status", context=RequestContext("promotion.get_order_status", allow_retry=True), + mapper=map_promotion_order_status, json_body=request.to_payload(), ) - return map_promotion_order_statuses(payload) @dataclass(slots=True) @@ -122,35 +130,50 @@ class BbipClient: def get_forecasts(self, request: CreateBbipForecastsRequest) -> BbipForecastsResult: """Получает прогнозы BBIP по объявлениям.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/promotion/v1/items/services/bbip/forecasts/get", context=RequestContext("promotion.bbip.get_forecasts", allow_retry=True), + mapper=map_bbip_forecasts, json_body=request.to_payload(), ) - return map_bbip_forecasts(payload) - def create_order(self, request: CreateBbipOrderRequest) -> PromotionActionResult: + def create_order( + self, + request: CreateBbipOrderRequest, + *, + action: str = "create_order", + target: Mapping[str, object] | None = None, + request_payload: Mapping[str, object] | None = None, + ) -> PromotionActionResult: """Подключает BBIP-услугу.""" + payload_to_send = dict(request_payload) if request_payload is not None else request.to_payload() payload = self.transport.request_json( "PUT", "/promotion/v1/items/services/bbip/orders/create", context=RequestContext("promotion.bbip.create_order", allow_retry=True), - json_body=request.to_payload(), + json_body=payload_to_send, + ) + return map_promotion_action( + payload, + action=action, + target=target or {"item_ids": [item.item_id for item in request.items]}, + request_payload=payload_to_send, ) - return map_promotion_action(payload) def get_suggests(self, request: CreateBbipSuggestsRequest) -> BbipSuggestsResult: """Получает варианты бюджета BBIP.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/promotion/v1/items/services/bbip/suggests/get", context=RequestContext("promotion.bbip.get_suggests", allow_retry=True), + mapper=map_bbip_suggests, json_body=request.to_payload(), ) - return map_bbip_suggests(payload) @dataclass(slots=True) @@ -159,39 +182,66 @@ class TrxPromoClient: transport: Transport - def apply(self, request: CreateTrxPromotionApplyRequest) -> PromotionActionResult: + def apply( + self, + request: CreateTrxPromotionApplyRequest, + *, + action: str = "apply", + target: Mapping[str, object] | None = None, + request_payload: Mapping[str, object] | None = None, + ) -> PromotionActionResult: """Запускает TrxPromo.""" + payload_to_send = dict(request_payload) if request_payload is not None else request.to_payload() payload = self.transport.request_json( "POST", "/trx-promo/1/apply", context=RequestContext("promotion.trx.apply", allow_retry=True), - json_body=request.to_payload(), + json_body=payload_to_send, + ) + return map_promotion_action( + payload, + action=action, + target=target or {"item_ids": [item.item_id for item in request.items]}, + request_payload=payload_to_send, ) - return map_promotion_action(payload) - def cancel(self, request: CancelTrxPromotionRequest) -> PromotionActionResult: + def cancel( + self, + request: CancelTrxPromotionRequest, + *, + action: str = "delete", + target: Mapping[str, object] | None = None, + request_payload: Mapping[str, object] | None = None, + ) -> PromotionActionResult: """Останавливает TrxPromo.""" + payload_to_send = dict(request_payload) if request_payload is not None else request.to_payload() payload = self.transport.request_json( "POST", "/trx-promo/1/cancel", context=RequestContext("promotion.trx.cancel", allow_retry=True), - json_body=request.to_payload(), + json_body=payload_to_send, + ) + return map_promotion_action( + payload, + action=action, + target=target or {"item_ids": list(request.item_ids)}, + request_payload=payload_to_send, ) - return map_promotion_action(payload) def get_commissions(self, *, item_ids: list[int] | None = None) -> TrxCommissionsResult: """Проверяет доступность TrxPromo и размер комиссий.""" params = {"itemIDs": ",".join(str(item_id) for item_id in item_ids)} if item_ids else None - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/trx-promo/1/commissions", context=RequestContext("promotion.trx.get_commissions"), + mapper=map_trx_commissions, params=params, ) - return map_trx_commissions(payload) @dataclass(slots=True) @@ -208,24 +258,38 @@ def get_user_bids( ) -> CpaAuctionBidsResult: """Получает действующие и доступные ставки.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/auction/1/bids", context=RequestContext("promotion.cpa_auction.get_user_bids"), + mapper=map_cpa_auction_bids, params={"fromItemID": from_item_id, "batchSize": batch_size}, ) - return map_cpa_auction_bids(payload) - def create_item_bids(self, request: CreateItemBidsRequest) -> PromotionActionResult: + def create_item_bids( + self, + request: CreateItemBidsRequest, + *, + action: str = "create_item_bids", + target: Mapping[str, object] | None = None, + request_payload: Mapping[str, object] | None = None, + ) -> PromotionActionResult: """Сохраняет новые ставки.""" + payload_to_send = dict(request_payload) if request_payload is not None else request.to_payload() payload = self.transport.request_json( "POST", "/auction/1/bids", context=RequestContext("promotion.cpa_auction.create_item_bids", allow_retry=True), - json_body=request.to_payload(), + json_body=payload_to_send, + ) + return map_promotion_action( + payload, + action=action, + target=target or {"item_ids": [item.item_id for item in request.items]}, + request_payload=payload_to_send, ) - return map_promotion_action(payload) @dataclass(slots=True) @@ -234,64 +298,105 @@ class TargetActionPriceClient: transport: Transport - def get_bids(self, *, item_id: int) -> TargetActionPromotionsResult: + def get_bids(self, *, item_id: int) -> TargetActionGetBidsResult: """Получает детализированные цены и бюджеты по объявлению.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", f"/cpxpromo/1/getBids/{item_id}", context=RequestContext("promotion.target_action.get_bids"), + mapper=map_target_action_get_bids_out, ) - return map_target_action_promotions(payload) def get_promotions_by_item_ids( self, request: GetPromotionsByItemIdsRequest, - ) -> TargetActionPromotionsResult: + ) -> TargetActionPromotionsByItemIdsResult: """Получает текущие цены и бюджеты по нескольким объявлениям.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/cpxpromo/1/getPromotionsByItemIds", context=RequestContext( "promotion.target_action.get_promotions_by_item_ids", allow_retry=True ), + mapper=map_target_action_get_promotions_by_item_ids_out, json_body=request.to_payload(), ) - return map_target_action_promotions(payload) - def delete_promotion(self, request: DeletePromotionRequest) -> PromotionActionResult: + def delete_promotion( + self, + request: DeletePromotionRequest, + *, + action: str = "delete", + target: Mapping[str, object] | None = None, + request_payload: Mapping[str, object] | None = None, + ) -> PromotionActionResult: """Останавливает продвижение с ценой целевого действия.""" + payload_to_send = dict(request_payload) if request_payload is not None else request.to_payload() payload = self.transport.request_json( "POST", "/cpxpromo/1/remove", context=RequestContext("promotion.target_action.delete_promotion", allow_retry=True), - json_body=request.to_payload(), + json_body=payload_to_send, + ) + return map_promotion_action( + payload, + action=action, + target=target or {"item_id": request.item_id}, + request_payload=payload_to_send, ) - return map_promotion_action(payload) - def update_auto_bid(self, request: UpdateAutoBidRequest) -> PromotionActionResult: + def update_auto_bid( + self, + request: UpdateAutoBidRequest, + *, + action: str = "update_auto", + target: Mapping[str, object] | None = None, + request_payload: Mapping[str, object] | None = None, + ) -> PromotionActionResult: """Применяет автоматическую настройку.""" + payload_to_send = dict(request_payload) if request_payload is not None else request.to_payload() payload = self.transport.request_json( "POST", "/cpxpromo/1/setAuto", context=RequestContext("promotion.target_action.update_auto_bid", allow_retry=True), - json_body=request.to_payload(), + json_body=payload_to_send, + ) + return map_promotion_action( + payload, + action=action, + target=target or {"item_id": request.item_id}, + request_payload=payload_to_send, ) - return map_promotion_action(payload) - def update_manual_bid(self, request: UpdateManualBidRequest) -> PromotionActionResult: + def update_manual_bid( + self, + request: UpdateManualBidRequest, + *, + action: str = "update_manual", + target: Mapping[str, object] | None = None, + request_payload: Mapping[str, object] | None = None, + ) -> PromotionActionResult: """Применяет ручную настройку.""" + payload_to_send = dict(request_payload) if request_payload is not None else request.to_payload() payload = self.transport.request_json( "POST", "/cpxpromo/1/setManual", context=RequestContext("promotion.target_action.update_manual_bid", allow_retry=True), - json_body=request.to_payload(), + json_body=payload_to_send, + ) + return map_promotion_action( + payload, + action=action, + target=target or {"item_id": request.item_id}, + request_payload=payload_to_send, ) - return map_promotion_action(payload) @dataclass(slots=True) @@ -303,79 +408,86 @@ class AutostrategyClient: def create_budget(self, request: CreateAutostrategyBudgetRequest) -> AutostrategyBudget: """Рассчитывает бюджет кампании.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/autostrategy/v1/budget", context=RequestContext("promotion.autostrategy.create_budget", allow_retry=True), + mapper=map_autostrategy_budget, json_body=request.to_payload(), ) - return map_autostrategy_budget(payload) def create_campaign(self, request: CreateAutostrategyCampaignRequest) -> CampaignActionResult: """Создает новую кампанию.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/autostrategy/v1/campaign/create", context=RequestContext("promotion.autostrategy.create_campaign", allow_retry=True), + mapper=map_campaign_action, json_body=request.to_payload(), ) - return map_campaign_action(payload) def edit_campaign(self, request: UpdateAutostrategyCampaignRequest) -> CampaignActionResult: """Редактирует кампанию.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/autostrategy/v1/campaign/edit", context=RequestContext("promotion.autostrategy.edit_campaign", allow_retry=True), + mapper=map_campaign_action, json_body=request.to_payload(), ) - return map_campaign_action(payload) def get_campaign_info(self, request: GetAutostrategyCampaignInfoRequest) -> CampaignInfo: """Получает полную информацию о кампании.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/autostrategy/v1/campaign/info", context=RequestContext("promotion.autostrategy.get_campaign_info", allow_retry=True), + mapper=map_campaign_info, json_body=request.to_payload(), ) - return map_campaign_info(payload) def stop_campaign(self, request: StopAutostrategyCampaignRequest) -> CampaignActionResult: """Останавливает кампанию.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/autostrategy/v1/campaign/stop", context=RequestContext("promotion.autostrategy.stop_campaign", allow_retry=True), + mapper=map_campaign_action, json_body=request.to_payload(), ) - return map_campaign_action(payload) def list_campaigns(self, request: ListAutostrategyCampaignsRequest) -> CampaignsResult: """Получает список кампаний.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/autostrategy/v1/campaigns", context=RequestContext("promotion.autostrategy.list_campaigns", allow_retry=True), + mapper=map_campaigns, json_body=request.to_payload(), ) - return map_campaigns(payload) def get_stat(self, request: GetAutostrategyStatRequest) -> AutostrategyStat: """Получает статистику кампании.""" - payload = self.transport.request_json( + return request_public_model( + self.transport, "POST", "/autostrategy/v1/stat", context=RequestContext("promotion.autostrategy.get_stat", allow_retry=True), + mapper=map_autostrategy_stat, json_body=request.to_payload(), ) - return map_autostrategy_stat(payload) __all__ = ( diff --git a/avito/promotion/domain.py b/avito/promotion/domain.py index 33b64b2..5136bcd 100644 --- a/avito/promotion/domain.py +++ b/avito/promotion/domain.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from dataclasses import dataclass -from avito.core import Transport +from avito.core import Transport, ValidationError from avito.promotion.client import ( AutostrategyClient, BbipClient, @@ -44,11 +44,12 @@ ListPromotionServicesRequest, PromotionActionResult, PromotionOrdersResult, - PromotionOrderStatusesResult, + PromotionOrderStatusResult, PromotionServiceDictionary, PromotionServicesResult, StopAutostrategyCampaignRequest, - TargetActionPromotionsResult, + TargetActionGetBidsResult, + TargetActionPromotionsByItemIdsResult, TrxCommissionsResult, TrxPromotionApplyItem, UpdateAutoBidRequest, @@ -57,6 +58,43 @@ ) +def _validate_non_empty_items(name: str, items: Sequence[object]) -> None: + if not items: + raise ValidationError(f"`{name}` must contain at least one item.") + + +def _validate_positive_int(name: str, value: int) -> None: + if value <= 0: + raise ValidationError(f"`{name}` must be a positive integer.") + + +def _validate_non_empty_string(name: str, value: str) -> None: + if not value.strip(): + raise ValidationError(f"`{name}` must be a non-empty string.") + + +def _validate_string_items(name: str, values: Sequence[str]) -> None: + _validate_non_empty_items(name, values) + for index, value in enumerate(values): + _validate_non_empty_string(f"{name}[{index}]", value) + + +def _preview_result( + *, + action: str, + target: Mapping[str, object], + request_payload: Mapping[str, object], +) -> PromotionActionResult: + return PromotionActionResult( + action=action, + target=dict(target), + status="preview", + applied=False, + request_payload=dict(request_payload), + details={"validated": True}, + ) + + @dataclass(slots=True, frozen=True) class DomainObject: """Базовый доменный объект раздела promotion.""" @@ -97,14 +135,14 @@ def list_orders( def get_order_status( self, *, order_ids: list[str] | None = None - ) -> PromotionOrderStatusesResult: + ) -> PromotionOrderStatusResult: """Получает статусы заявок на продвижение.""" resolved_order_ids = order_ids or ( [str(self.resource_id)] if self.resource_id is not None else [] ) if not resolved_order_ids: - raise ValueError("Для операции требуется хотя бы один `order_id`.") + raise ValidationError("Для операции требуется хотя бы один `order_id`.") return PromotionClient(self.transport).get_order_status( GetPromotionOrderStatusRequest(order_ids=resolved_order_ids) ) @@ -122,10 +160,35 @@ def get_forecasts(self, *, items: list[BbipForecastRequestItem]) -> BbipForecast return BbipClient(self.transport).get_forecasts(CreateBbipForecastsRequest(items=items)) - def create_order(self, *, items: list[BbipOrderItem]) -> PromotionActionResult: + def create_order( + self, + *, + items: list[BbipOrderItem], + dry_run: bool = False, + ) -> PromotionActionResult: """Подключает BBIP-продвижение.""" - return BbipClient(self.transport).create_order(CreateBbipOrderRequest(items=items)) + _validate_non_empty_items("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) + request = CreateBbipOrderRequest(items=items) + request_payload = request.to_payload() + target = {"item_ids": [item.item_id for item in items]} + if dry_run: + return _preview_result( + action="create_order", + target=target, + request_payload=request_payload, + ) + return BbipClient(self.transport).create_order( + request, + action="create_order", + target=target, + request_payload=request_payload, + ) def get_suggests(self, *, item_ids: list[int] | None = None) -> BbipSuggestsResult: """Получает варианты бюджета BBIP.""" @@ -137,7 +200,7 @@ def get_suggests(self, *, item_ids: list[int] | None = None) -> BbipSuggestsResu def _resource_item_ids(self) -> list[int]: if self.resource_id is None: - raise ValueError("Для операции требуется `item_id` или список `item_ids`.") + raise ValidationError("Для операции требуется `item_id` или список `item_ids`.") return [int(self.resource_id)] @@ -148,17 +211,53 @@ class TrxPromotion(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def apply(self, *, items: list[TrxPromotionApplyItem]) -> PromotionActionResult: + def apply( + self, + *, + items: list[TrxPromotionApplyItem], + dry_run: bool = False, + ) -> PromotionActionResult: """Запускает TrxPromo.""" - return TrxPromoClient(self.transport).apply(CreateTrxPromotionApplyRequest(items=items)) + _validate_non_empty_items("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) + _validate_non_empty_string(f"items[{index}].date_from", item.date_from) + if item.date_to is not None: + _validate_non_empty_string(f"items[{index}].date_to", item.date_to) + request = CreateTrxPromotionApplyRequest(items=items) + request_payload = request.to_payload() + target = {"item_ids": [item.item_id for item in items]} + if dry_run: + return _preview_result(action="apply", target=target, request_payload=request_payload) + return TrxPromoClient(self.transport).apply( + request, + action="apply", + target=target, + request_payload=request_payload, + ) - def delete(self, *, item_ids: list[int] | None = None) -> PromotionActionResult: + def delete( + self, + *, + item_ids: list[int] | None = None, + dry_run: bool = False, + ) -> PromotionActionResult: """Останавливает TrxPromo.""" resolved_item_ids = item_ids or self._resource_item_ids() + _validate_non_empty_items("item_ids", resolved_item_ids) + request = CancelTrxPromotionRequest(item_ids=resolved_item_ids) + request_payload = request.to_payload() + target = {"item_ids": list(resolved_item_ids)} + if dry_run: + return _preview_result(action="delete", target=target, request_payload=request_payload) return TrxPromoClient(self.transport).cancel( - CancelTrxPromotionRequest(item_ids=resolved_item_ids) + request, + action="delete", + target=target, + request_payload=request_payload, ) def get_commissions(self, *, item_ids: list[int] | None = None) -> TrxCommissionsResult: @@ -170,7 +269,7 @@ def get_commissions(self, *, item_ids: list[int] | None = None) -> TrxCommission def _resource_item_ids(self) -> list[int]: if self.resource_id is None: - raise ValueError("Для операции требуется `item_id` или список `item_ids`.") + raise ValidationError("Для операции требуется `item_id` или список `item_ids`.") return [int(self.resource_id)] @@ -207,7 +306,7 @@ class TargetActionPricing(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def get_bids(self, *, item_id: int | None = None) -> TargetActionPromotionsResult: + def get_bids(self, *, item_id: int | None = None) -> TargetActionGetBidsResult: """Получает детализированные цены и бюджеты.""" return TargetActionPriceClient(self.transport).get_bids( @@ -216,7 +315,7 @@ def get_bids(self, *, item_id: int | None = None) -> TargetActionPromotionsResul def get_promotions_by_item_ids( self, *, item_ids: list[int] | None = None - ) -> TargetActionPromotionsResult: + ) -> TargetActionPromotionsByItemIdsResult: """Получает текущие настройки по нескольким объявлениям.""" resolved_item_ids = item_ids or [self._require_item_id()] @@ -224,11 +323,26 @@ def get_promotions_by_item_ids( GetPromotionsByItemIdsRequest(item_ids=resolved_item_ids) ) - def delete(self, *, item_id: int | None = None) -> PromotionActionResult: + def delete( + self, + *, + item_id: int | None = None, + dry_run: bool = False, + ) -> PromotionActionResult: """Останавливает продвижение.""" + resolved_item_id = item_id or self._require_item_id() + _validate_positive_int("item_id", resolved_item_id) + request = DeletePromotionRequest(item_id=resolved_item_id) + request_payload = request.to_payload() + target = {"item_id": resolved_item_id} + if dry_run: + return _preview_result(action="delete", target=target, request_payload=request_payload) return TargetActionPriceClient(self.transport).delete_promotion( - DeletePromotionRequest(item_id=item_id or self._require_item_id()) + request, + action="delete", + target=target, + request_payload=request_payload, ) def update_auto( @@ -238,16 +352,34 @@ def update_auto( budget_penny: int, budget_type: str, item_id: int | None = None, + dry_run: bool = False, ) -> PromotionActionResult: """Применяет автоматическую настройку.""" - return TargetActionPriceClient(self.transport).update_auto_bid( - UpdateAutoBidRequest( - item_id=item_id or self._require_item_id(), - action_type_id=action_type_id, - budget_penny=budget_penny, - budget_type=budget_type, + 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 = UpdateAutoBidRequest( + item_id=resolved_item_id, + action_type_id=action_type_id, + budget_penny=budget_penny, + budget_type=budget_type, + ) + request_payload = request.to_payload() + target = {"item_id": resolved_item_id} + if dry_run: + return _preview_result( + action="update_auto", + target=target, + request_payload=request_payload, ) + return TargetActionPriceClient(self.transport).update_auto_bid( + request, + action="update_auto", + target=target, + request_payload=request_payload, ) def update_manual( @@ -257,21 +389,40 @@ def update_manual( bid_penny: int, limit_penny: int | None = None, item_id: int | None = None, + dry_run: bool = False, ) -> PromotionActionResult: """Применяет ручную настройку.""" - return TargetActionPriceClient(self.transport).update_manual_bid( - UpdateManualBidRequest( - item_id=item_id or self._require_item_id(), - action_type_id=action_type_id, - bid_penny=bid_penny, - limit_penny=limit_penny, + 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 = UpdateManualBidRequest( + item_id=resolved_item_id, + action_type_id=action_type_id, + bid_penny=bid_penny, + limit_penny=limit_penny, + ) + request_payload = request.to_payload() + target = {"item_id": resolved_item_id} + if dry_run: + return _preview_result( + action="update_manual", + target=target, + request_payload=request_payload, ) + return TargetActionPriceClient(self.transport).update_manual_bid( + request, + action="update_manual", + target=target, + request_payload=request_payload, ) def _require_item_id(self) -> int: if self.resource_id is None: - raise ValueError("Для операции требуется `item_id`.") + raise ValidationError("Для операции требуется `item_id`.") return int(self.resource_id) @@ -282,26 +433,20 @@ class AutostrategyCampaign(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def create_budget(self, *, payload: Mapping[str, object]) -> AutostrategyBudget: + def create_budget(self, *, request: CreateAutostrategyBudgetRequest) -> AutostrategyBudget: """Рассчитывает бюджет кампании.""" - return AutostrategyClient(self.transport).create_budget( - CreateAutostrategyBudgetRequest(payload=payload) - ) + return AutostrategyClient(self.transport).create_budget(request) - def create(self, *, payload: Mapping[str, object]) -> CampaignActionResult: + def create(self, *, request: CreateAutostrategyCampaignRequest) -> CampaignActionResult: """Создает новую кампанию.""" - return AutostrategyClient(self.transport).create_campaign( - CreateAutostrategyCampaignRequest(payload=payload) - ) + return AutostrategyClient(self.transport).create_campaign(request) - def update(self, *, payload: Mapping[str, object]) -> CampaignActionResult: + def update(self, *, request: UpdateAutostrategyCampaignRequest) -> CampaignActionResult: """Редактирует кампанию.""" - return AutostrategyClient(self.transport).edit_campaign( - UpdateAutostrategyCampaignRequest(payload=payload) - ) + return AutostrategyClient(self.transport).edit_campaign(request) def get(self, *, campaign_id: int | None = None) -> CampaignInfo: """Получает полную информацию о кампании.""" @@ -319,11 +464,11 @@ def delete(self, *, campaign_id: int | None = None) -> CampaignActionResult: StopAutostrategyCampaignRequest(campaign_id=campaign_id or self._require_campaign_id()) ) - def list(self, *, payload: Mapping[str, object] | None = None) -> CampaignsResult: + def list(self, *, request: ListAutostrategyCampaignsRequest | None = None) -> CampaignsResult: """Получает список кампаний.""" return AutostrategyClient(self.transport).list_campaigns( - ListAutostrategyCampaignsRequest(payload=payload or {}) + request or ListAutostrategyCampaignsRequest(payload={}) ) def get_stat(self, *, campaign_id: int | None = None) -> AutostrategyStat: @@ -335,7 +480,7 @@ def get_stat(self, *, campaign_id: int | None = None) -> AutostrategyStat: def _require_campaign_id(self) -> int: if self.resource_id is None: - raise ValueError("Для операции требуется `campaign_id`.") + raise ValidationError("Для операции требуется `campaign_id`.") return int(self.resource_id) diff --git a/avito/promotion/mappers.py b/avito/promotion/mappers.py index c01bf48..2660208 100644 --- a/avito/promotion/mappers.py +++ b/avito/promotion/mappers.py @@ -25,17 +25,24 @@ CpaAuctionItemBid, PromotionActionItem, PromotionActionResult, + PromotionOrderError, PromotionOrderInfo, PromotionOrdersResult, - PromotionOrderStatus, - PromotionOrderStatusesResult, + PromotionOrderStatusItem, + PromotionOrderStatusResult, PromotionService, PromotionServiceDictionary, PromotionServicesResult, PromotionServiceType, + TargetActionAutoBids, + TargetActionAutoPromotion, TargetActionBid, + TargetActionBudget, + TargetActionGetBidsResult, + TargetActionManualBids, + TargetActionManualPromotion, TargetActionPromotion, - TargetActionPromotionsResult, + TargetActionPromotionsByItemIdsResult, TrxCommissionInfo, TrxCommissionRange, TrxCommissionsResult, @@ -105,11 +112,11 @@ def map_promotion_service_dictionary(payload: object) -> PromotionServiceDiction PromotionServiceType( code=_str(item, "code", "serviceCode", "id"), title=_str(item, "title", "name", "description"), - raw_payload=item, + _payload=item, ) for item in _items_payload(data) ], - raw_payload=data, + _payload=data, ) @@ -125,11 +132,11 @@ def map_promotion_services(payload: object) -> PromotionServicesResult: service_name=_str(item, "serviceName", "name", "title"), price=_int(item, "price", "pricePenny"), status=_str(item, "status"), - raw_payload=item, + _payload=item, ) for item in _items_payload(data) ], - raw_payload=data, + _payload=data, ) @@ -145,29 +152,49 @@ def map_promotion_orders(payload: object) -> PromotionOrdersResult: service_code=_str(item, "serviceCode", "code"), status=_str(item, "status"), created_at=_str(item, "createdAt", "created_at"), - raw_payload=item, ) for item in _items_payload(data) ], - raw_payload=data, ) -def map_promotion_order_statuses(payload: object) -> PromotionOrderStatusesResult: - """Преобразует статусы заявок на продвижение.""" +def map_promotion_order_status(payload: object) -> PromotionOrderStatusResult: + """Преобразует documented shape статуса заявки на продвижение.""" data = _expect_mapping(payload) - return PromotionOrderStatusesResult( + order_id = _str(data, "orderId", "orderID", "id") + status = _str(data, "status") + if order_id is None or status is None: + raise ResponseMappingError( + "Статус заявки promotion должен содержать `orderId` и `status`.", + payload=payload, + ) + errors_payload = data.get("errors", []) + if errors_payload is not None and not isinstance(errors_payload, list): + raise ResponseMappingError("Поле `errors` должно быть массивом.", payload=payload) + return PromotionOrderStatusResult( + order_id=order_id, + status=status, + total_price=_int(data, "totalPrice"), items=[ - PromotionOrderStatus( - order_id=_str(item, "orderId", "orderID", "id"), + PromotionOrderStatusItem( + item_id=_int(item, "itemId", "itemID"), + price=_int(item, "price"), + slug=_str(item, "slug"), status=_str(item, "status"), - message=_str(item, "message", "description"), - raw_payload=item, + error_reason=_str(item, "errorReason"), ) - for item in _items_payload(data) + for item in _list(data, "items") + ], + errors=[ + PromotionOrderError( + item_id=_int(item, "itemId", "itemID"), + error_code=_int(item, "errorCode"), + error_text=_str(item, "errorText"), + ) + for item in errors_payload or [] + if isinstance(item, Mapping) ], - raw_payload=data, ) @@ -183,15 +210,19 @@ def map_bbip_forecasts(payload: object) -> BbipForecastsResult: max_views=_int(item, "max"), total_price=_int(item, "totalPrice"), total_old_price=_int(item, "totalOldPrice"), - raw_payload=item, ) for item in _items_payload(data) ], - raw_payload=data, ) -def map_promotion_action(payload: object) -> PromotionActionResult: +def map_promotion_action( + payload: object, + *, + action: str, + target: Mapping[str, object] | None, + request_payload: Mapping[str, object], +) -> PromotionActionResult: """Преобразует результат действия по продвижению.""" data = _expect_mapping(payload) @@ -199,21 +230,65 @@ def map_promotion_action(payload: object) -> PromotionActionResult: if not items_payload: success_payload = _mapping(data, "success") items_payload = _list(success_payload, "items", "result") + items = [ + PromotionActionItem( + item_id=_int(item, "itemId", "itemID"), + success=bool(item.get("success", True)), + status=_str(item, "status"), + message=_str(_mapping(item, "error"), "message") or _str(item, "message"), + _payload=item, + ) + for item in items_payload + ] + applied = bool(data.get("success", True)) if not items else all(item.success for item in items) + statuses = [item.status for item in items if item.status] + messages = [item.message for item in items if item.message] + resolved_status = _resolve_action_status(payload=data, statuses=statuses, applied=applied) + details: dict[str, object] = {} + if items: + details["items"] = [item.to_dict() for item in items] + elif message := _str(data, "message", "status"): + details["message"] = message return PromotionActionResult( - items=[ - PromotionActionItem( - item_id=_int(item, "itemId", "itemID"), - success=bool(item.get("success", True)), - status=_str(item, "status"), - message=_str(_mapping(item, "error"), "message") or _str(item, "message"), - raw_payload=item, - ) - for item in items_payload - ], - raw_payload=data, + action=action, + target=dict(target) if target is not None else None, + status=resolved_status, + applied=applied, + request_payload=dict(request_payload), + warnings=messages if not applied else [], + upstream_reference=_extract_upstream_reference(data, items), + details=details, + _payload=data, ) +def _resolve_action_status(*, payload: Payload, statuses: list[str], applied: bool) -> str: + if statuses: + unique_statuses = list(dict.fromkeys(statuses)) + if len(unique_statuses) == 1: + return unique_statuses[0] + return "applied" if applied else "partial" + payload_status = _str(payload, "status") + if payload_status is not None: + return payload_status + return "applied" if applied else "failed" + + +def _extract_upstream_reference( + payload: Payload, + items: list[PromotionActionItem], +) -> str | None: + reference = _str(payload, "orderId", "requestId", "promotionId", "id") + if reference is not None: + return reference + for item in items: + item_payload = _expect_mapping(item._payload) + reference = _str(item_payload, "orderId", "requestId", "promotionId", "id") + if reference is not None: + return reference + return None + + def map_bbip_suggests(payload: object) -> BbipSuggestsResult: """Преобразует варианты бюджета BBIP.""" @@ -224,11 +299,11 @@ def map_bbip_suggests(payload: object) -> BbipSuggestsResult: item_id=_int(item, "itemId", "itemID"), duration=_map_bbip_duration(_mapping(item, "duration")), budgets=[_map_bbip_budget(option) for option in _list(item, "budgets")], - raw_payload=item, + _payload=item, ) for item in _items_payload(data) ], - raw_payload=data, + _payload=data, ) @@ -237,7 +312,7 @@ def _map_bbip_budget(payload: Payload) -> BbipBudgetOption: price=_int(payload, "price"), old_price=_int(payload, "oldPrice"), is_recommended=_bool(payload, "isRecommended"), - raw_payload=payload, + _payload=payload, ) @@ -248,7 +323,7 @@ def _map_bbip_duration(payload: Payload) -> BbipDurationRange | None: start=_int(payload, "from"), stop=_int(payload, "to"), recommended=_int(payload, "recommended"), - raw_payload=payload, + _payload=payload, ) @@ -267,11 +342,11 @@ def map_trx_commissions(payload: object) -> TrxCommissionsResult: commission=_int(item, "commission"), is_active=_bool(item, "isActive", "active"), valid_commission_range=_map_trx_range(_mapping(item, "validCommissionRange")), - raw_payload=item, + _payload=item, ) for item in items_payload ], - raw_payload=data, + _payload=data, ) @@ -282,7 +357,7 @@ def _map_trx_range(payload: Payload) -> TrxCommissionRange | None: value_min=_int(payload, "valueMin"), value_max=_int(payload, "valueMax"), step=_int(payload, "step"), - raw_payload=payload, + _payload=payload, ) @@ -300,46 +375,151 @@ def map_cpa_auction_bids(payload: object) -> CpaAuctionBidsResult: CpaAuctionBidOption( price_penny=_int(option, "pricePenny"), goodness=_int(option, "goodness"), - raw_payload=option, + _payload=option, ) for option in _list(item, "availablePrices") ], - raw_payload=item, + _payload=item, ) for item in _items_payload(data) ], - raw_payload=data, + _payload=data, + ) + + +def _map_target_action_bid(item: Payload) -> TargetActionBid: + return TargetActionBid( + value_penny=_int(item, "valuePenny"), + min_forecast=_int(item, "minForecast"), + max_forecast=_int(item, "maxForecast"), + compare=_int(item, "compare"), + ) + + +def _map_target_action_budget(item: Payload) -> TargetActionBudget: + return TargetActionBudget( + budget_penny=_int(item, "valuePenny"), + min_forecast=_int(item, "minForecast"), + max_forecast=_int(item, "maxForecast"), + compare=_int(item, "compare"), + ) + + +def _map_target_action_manual(payload: Payload) -> TargetActionManualBids: + bids_payload = payload.get("bids") + if bids_payload is not None and not isinstance(bids_payload, list): + raise ResponseMappingError("Поле `manual.bids` должно быть массивом.", payload=payload) + return TargetActionManualBids( + bid_penny=_int(payload, "bidPenny"), + limit_penny=_int(payload, "limitPenny"), + rec_bid_penny=_int(payload, "recBidPenny"), + min_bid_penny=_int(payload, "minBidPenny"), + max_bid_penny=_int(payload, "maxBidPenny"), + min_limit_penny=_int(payload, "minLimitPenny"), + max_limit_penny=_int(payload, "maxLimitPenny"), + bids=[ + _map_target_action_bid(item) + for item in bids_payload or [] + if isinstance(item, Mapping) + ], ) -def map_target_action_promotions(payload: object) -> TargetActionPromotionsResult: - """Преобразует текущие настройки цены целевого действия.""" +def _map_budget_values(payload: Payload, key: str) -> list[TargetActionBudget]: + budget = payload.get(key) + if budget is None: + return [] + if not isinstance(budget, Mapping): + raise ResponseMappingError(f"Поле `{key}` должно быть объектом.", payload=payload) + values = budget.get("budgets") + if values is not None and not isinstance(values, list): + raise ResponseMappingError(f"Поле `{key}.budgets` должно быть массивом.", payload=payload) + return [_map_target_action_budget(item) for item in values or [] if isinstance(item, Mapping)] + + +def _map_target_action_auto(payload: Payload) -> TargetActionAutoBids: + return TargetActionAutoBids( + budget_penny=_int(payload, "budgetPenny"), + budget_type=_str(payload, "budgetType"), + min_budget_penny=_int(payload, "minBudgetPenny"), + max_budget_penny=_int(payload, "maxBudgetPenny"), + daily_budget=_map_budget_values(payload, "dailyBudget"), + weekly_budget=_map_budget_values(payload, "weeklyBudget"), + monthly_budget=_map_budget_values(payload, "monthlyBudget"), + ) + + +def map_target_action_get_bids_out(payload: object) -> TargetActionGetBidsResult: + """Преобразует documented shape GET /cpxpromo/1/getBids/{itemId}.""" data = _expect_mapping(payload) - return TargetActionPromotionsResult( - items=[ + action_type_id = _int(data, "actionTypeID") + selected_type = _str(data, "selectedType") + if action_type_id is None or selected_type is None: + raise ResponseMappingError( + "Ответ getBids должен содержать `actionTypeID` и `selectedType`.", + payload=payload, + ) + return TargetActionGetBidsResult( + action_type_id=action_type_id, + selected_type=selected_type, + auto=( + _map_target_action_auto(cast(Payload, data["auto"])) + if isinstance(data.get("auto"), Mapping) + else None + ), + manual=( + _map_target_action_manual(cast(Payload, data["manual"])) + if isinstance(data.get("manual"), Mapping) + else None + ), + ) + + +def map_target_action_get_promotions_by_item_ids_out( + payload: object, +) -> TargetActionPromotionsByItemIdsResult: + """Преобразует documented shape POST /cpxpromo/1/getPromotionsByItemIds.""" + + data = _expect_mapping(payload) + items_payload = data.get("items") + if not isinstance(items_payload, list): + raise ResponseMappingError("Ответ getPromotionsByItemIds должен содержать массив `items`.", payload=payload) + items: list[TargetActionPromotion] = [] + for item in items_payload: + if not isinstance(item, Mapping): + continue + item_id = _int(item, "itemID") + action_type_id = _int(item, "actionTypeID") + if item_id is None or action_type_id is None: + raise ResponseMappingError( + "Элемент getPromotionsByItemIds должен содержать `itemID` и `actionTypeID`.", + payload=item, + ) + items.append( TargetActionPromotion( - item_id=_int(item, "itemID", "itemId"), - action_type_id=_int(item, "actionTypeID", "actionTypeId"), - is_auto=_bool(item, "isAuto", "auto"), - bid_penny=_int(item, "bidPenny"), - budget_penny=_int(item, "budgetPenny"), - budget_type=_str(item, "budgetType"), - available_bids=[ - TargetActionBid( - value_penny=_int(option, "valuePenny"), - min_forecast=_int(option, "minForecast"), - max_forecast=_int(option, "maxForecast"), - compare=_int(option, "compare"), - raw_payload=option, + item_id=item_id, + action_type_id=action_type_id, + auto=( + TargetActionAutoPromotion( + budget_penny=_int(cast(Payload, item["autoPromotion"]), "budgetPenny"), + budget_type=_str(cast(Payload, item["autoPromotion"]), "budgetType"), ) - for option in _list(item, "availableBids", "bids") - ], - raw_payload=item, + if isinstance(item.get("autoPromotion"), Mapping) + else None + ), + manual=( + TargetActionManualPromotion( + bid_penny=_int(cast(Payload, item["manualPromotion"]), "bidPenny"), + limit_penny=_int(cast(Payload, item["manualPromotion"]), "limitPenny"), + ) + if isinstance(item.get("manualPromotion"), Mapping) + else None + ), ) - for item in _items_payload(data) - ], - raw_payload=data, + ) + return TargetActionPromotionsByItemIdsResult( + items=items, ) @@ -355,7 +535,7 @@ def map_autostrategy_budget(payload: object) -> AutostrategyBudget: minimal=_map_budget_point(_mapping(source, "minimal")), maximal=_map_budget_point(_mapping(source, "maximal")), price_ranges=[_map_price_range(item) for item in _list(source, "priceRanges")], - raw_payload=data, + _payload=data, ) @@ -370,7 +550,7 @@ def _map_budget_point(payload: Payload) -> AutostrategyBudgetPoint | None: calls_to=_int(payload, "callsTo"), views_from=_int(payload, "viewsFrom"), views_to=_int(payload, "viewsTo"), - raw_payload=payload, + _payload=payload, ) @@ -383,7 +563,7 @@ def _map_price_range(payload: Payload) -> AutostrategyPriceRange: calls_to=_int(payload, "callsTo"), views_from=_int(payload, "viewsFrom"), views_to=_int(payload, "viewsTo"), - raw_payload=payload, + _payload=payload, ) @@ -394,7 +574,7 @@ def map_campaign_action(payload: object) -> CampaignActionResult: return CampaignActionResult( campaign_id=_int(data, "campaignId", "campaignID", "id"), status=_str(data, "status"), - raw_payload=data, + _payload=data, ) @@ -410,7 +590,7 @@ def map_campaign_info(payload: object) -> CampaignInfo: budget=_int(source, "budget"), balance=_int(source, "balance"), title=_str(source, "title", "name"), - raw_payload=data, + _payload=data, ) @@ -420,7 +600,7 @@ def map_campaigns(payload: object) -> CampaignsResult: data = _expect_mapping(payload) return CampaignsResult( items=[map_campaign_info(item) for item in _items_payload(data)], - raw_payload=data, + _payload=data, ) @@ -434,5 +614,5 @@ def map_autostrategy_stat(payload: object) -> AutostrategyStat: views=_int(source, "views"), contacts=_int(source, "contacts", "leads"), spend=_int(source, "spend", "spendTotal"), - raw_payload=data, + _payload=data, ) diff --git a/avito/promotion/models.py b/avito/promotion/models.py index ef37cbe..1a8d67c 100644 --- a/avito/promotion/models.py +++ b/avito/promotion/models.py @@ -5,22 +5,24 @@ from collections.abc import Mapping from dataclasses import dataclass, field +from avito.core.serialization import SerializableModel, enable_module_serialization + @dataclass(slots=True, frozen=True) -class PromotionServiceType: +class PromotionServiceType(SerializableModel): """Тип услуги продвижения.""" code: str | None title: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class PromotionServiceDictionary: +class PromotionServiceDictionary(SerializableModel): """Словарь услуг продвижения.""" items: list[PromotionServiceType] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -36,7 +38,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class PromotionService: +class PromotionService(SerializableModel): """Услуга продвижения по объявлению.""" item_id: int | None @@ -44,15 +46,15 @@ class PromotionService: service_name: str | None price: int | None status: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class PromotionServicesResult: +class PromotionServicesResult(SerializableModel): """Список услуг продвижения.""" items: list[PromotionService] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -74,7 +76,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class PromotionOrderInfo: +class PromotionOrderInfo(SerializableModel): """Заявка на продвижение.""" order_id: str | None @@ -82,15 +84,13 @@ class PromotionOrderInfo: service_code: str | None status: str | None created_at: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class PromotionOrdersResult: +class PromotionOrdersResult(SerializableModel): """Список заявок на продвижение.""" items: list[PromotionOrderInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -106,21 +106,34 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class PromotionOrderStatus: - """Статус заявки на продвижение.""" +class PromotionOrderError(SerializableModel): + """Ошибка по объявлению в ответе promotion API.""" - order_id: str | None + item_id: int | None + error_code: int | None + error_text: str | None + + +@dataclass(slots=True, frozen=True) +class PromotionOrderStatusItem(SerializableModel): + """Статус услуги внутри заявки на продвижение.""" + + item_id: int | None + price: int | None + slug: str | None status: str | None - message: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + error_reason: str | None @dataclass(slots=True, frozen=True) -class PromotionOrderStatusesResult: - """Набор статусов заявок.""" +class PromotionOrderStatusResult(SerializableModel): + """Статус заявки на продвижение.""" - items: list[PromotionOrderStatus] - raw_payload: Mapping[str, object] = field(default_factory=dict) + order_id: str | None + status: str | None + total_price: int | None + items: list[PromotionOrderStatusItem] + errors: list[PromotionOrderError] @dataclass(slots=True, frozen=True) @@ -156,7 +169,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class BbipForecast: +class BbipForecast(SerializableModel): """Прогноз BBIP по объявлению.""" item_id: int | None @@ -164,15 +177,13 @@ class BbipForecast: max_views: int | None total_price: int | None total_old_price: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class BbipForecastsResult: +class BbipForecastsResult(SerializableModel): """Результат прогноза BBIP.""" items: list[BbipForecast] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -208,22 +219,29 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class PromotionActionItem: +class PromotionActionItem(SerializableModel): """Результат действия по одному объявлению.""" item_id: int | None success: bool status: str | None = None message: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class PromotionActionResult: - """Результат операции продвижения.""" +class PromotionActionResult(SerializableModel): + """Стабильный результат write-операции продвижения.""" - items: list[PromotionActionItem] - raw_payload: Mapping[str, object] = field(default_factory=dict) + action: str + target: Mapping[str, object] | None + status: str + applied: bool + request_payload: Mapping[str, object] + warnings: list[str] = field(default_factory=list) + upstream_reference: str | None = None + details: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -239,41 +257,41 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class BbipBudgetOption: +class BbipBudgetOption(SerializableModel): """Вариант бюджета BBIP.""" price: int | None old_price: int | None is_recommended: bool | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class BbipDurationRange: +class BbipDurationRange(SerializableModel): """Доступный диапазон длительности BBIP.""" start: int | None stop: int | None recommended: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class BbipSuggest: +class BbipSuggest(SerializableModel): """Варианты бюджета BBIP по объявлению.""" item_id: int | None duration: BbipDurationRange | None budgets: list[BbipBudgetOption] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class BbipSuggestsResult: +class BbipSuggestsResult(SerializableModel): """Результат вариантов бюджета BBIP.""" items: list[BbipSuggest] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -323,60 +341,60 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class TrxCommissionRange: +class TrxCommissionRange(SerializableModel): """Диапазон комиссии TrxPromo.""" value_min: int | None value_max: int | None step: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class TrxCommissionInfo: +class TrxCommissionInfo(SerializableModel): """Доступность и комиссия TrxPromo по объявлению.""" item_id: int | None commission: int | None is_active: bool | None valid_commission_range: TrxCommissionRange | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class TrxCommissionsResult: +class TrxCommissionsResult(SerializableModel): """Список комиссий TrxPromo.""" items: list[TrxCommissionInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaAuctionBidOption: +class CpaAuctionBidOption(SerializableModel): """Доступная ставка CPA-аукциона.""" price_penny: int | None goodness: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaAuctionItemBid: +class CpaAuctionItemBid(SerializableModel): """Текущая и доступные ставки CPA-аукциона по объявлению.""" item_id: int | None price_penny: int | None expiration_time: str | None available_prices: list[CpaAuctionBidOption] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class CpaAuctionBidsResult: +class CpaAuctionBidsResult(SerializableModel): """Список ставок CPA-аукциона.""" items: list[CpaAuctionItemBid] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -405,45 +423,93 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class TargetActionBid: +class TargetActionBid(SerializableModel): """Доступная цена целевого действия.""" value_penny: int | None min_forecast: int | None max_forecast: int | None compare: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class TargetActionBudget: - """Бюджет целевого действия.""" +class TargetActionManualBids(SerializableModel): + """Детали ручной ставки цены целевого действия.""" + + bid_penny: int | None + limit_penny: int | None + rec_bid_penny: int | None + min_bid_penny: int | None + max_bid_penny: int | None + min_limit_penny: int | None + max_limit_penny: int | None + bids: list[TargetActionBid] + + +@dataclass(slots=True, frozen=True) +class TargetActionBudget(SerializableModel): + """Диапазон доступных бюджетов цены целевого действия.""" + + budget_penny: int | None + min_forecast: int | None + max_forecast: int | None + compare: int | None + + +@dataclass(slots=True, frozen=True) +class TargetActionAutoBids(SerializableModel): + """Детали автоматического продвижения цены целевого действия.""" budget_penny: int | None budget_type: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + min_budget_penny: int | None + max_budget_penny: int | None + daily_budget: list[TargetActionBudget] + weekly_budget: list[TargetActionBudget] + monthly_budget: list[TargetActionBudget] @dataclass(slots=True, frozen=True) -class TargetActionPromotion: - """Текущая настройка цены целевого действия.""" +class TargetActionGetBidsResult(SerializableModel): + """Ответ GET /cpxpromo/1/getBids/{itemId}.""" + + action_type_id: int + selected_type: str + auto: TargetActionAutoBids | None = None + manual: TargetActionManualBids | None = None + + +@dataclass(slots=True, frozen=True) +class TargetActionPromotion(SerializableModel): + """Текущая настройка цены целевого действия по объявлению.""" + + item_id: int + action_type_id: int + auto: TargetActionAutoPromotion | None = None + manual: TargetActionManualPromotion | None = None + + +@dataclass(slots=True, frozen=True) +class TargetActionAutoPromotion(SerializableModel): + """Текущий auto-budget по объявлению.""" - item_id: int | None - action_type_id: int | None - is_auto: bool | None - bid_penny: int | None budget_penny: int | None budget_type: str | None - available_bids: list[TargetActionBid] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class TargetActionPromotionsResult: - """Набор текущих настроек цены целевого действия.""" +class TargetActionManualPromotion(SerializableModel): + """Текущая manual-настройка по объявлению.""" + + bid_penny: int | None + limit_penny: int | None + + +@dataclass(slots=True, frozen=True) +class TargetActionPromotionsByItemIdsResult(SerializableModel): + """Ответ POST /cpxpromo/1/getPromotionsByItemIds.""" items: list[TargetActionPromotion] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -523,7 +589,7 @@ class AutostrategyBudgetPoint: calls_to: int | None views_from: int | None views_to: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -537,7 +603,7 @@ class AutostrategyPriceRange: calls_to: int | None views_from: int | None views_to: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -549,7 +615,7 @@ class AutostrategyBudget: minimal: AutostrategyBudgetPoint | None maximal: AutostrategyBudgetPoint | None price_ranges: list[AutostrategyPriceRange] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -570,7 +636,7 @@ class CampaignActionResult: campaign_id: int | None status: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -583,7 +649,7 @@ class CampaignInfo: budget: int | None balance: int | None title: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -591,7 +657,7 @@ class CampaignsResult: """Список автокампаний.""" items: list[CampaignInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -602,7 +668,7 @@ class AutostrategyStat: views: int | None contacts: int | None spend: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -675,3 +741,9 @@ def to_payload(self) -> dict[str, object]: """Сериализует запрос статистики кампании.""" return {"campaignId": self.campaign_id} + + +PromotionOrder = PromotionOrderInfo +PromotionForecast = BbipForecast + +enable_module_serialization(globals()) diff --git a/avito/ratings/client.py b/avito/ratings/client.py index 427c813..d1845f5 100644 --- a/avito/ratings/client.py +++ b/avito/ratings/client.py @@ -2,12 +2,17 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass from avito.core import RequestContext, Transport from avito.ratings.mappers import map_rating_profile, map_review_answer, map_reviews -from avito.ratings.models import JsonRequest, RatingProfileInfo, ReviewAnswerInfo, ReviewsResult +from avito.ratings.models import ( + CreateReviewAnswerRequest, + RatingProfileInfo, + ReviewAnswerInfo, + ReviewsQuery, + ReviewsResult, +) @dataclass(slots=True) @@ -16,7 +21,7 @@ class RatingsClient: transport: Transport - def create_review_answer_v1(self, request: JsonRequest) -> ReviewAnswerInfo: + def create_review_answer_v1(self, request: CreateReviewAnswerRequest) -> ReviewAnswerInfo: payload = self.transport.request_json( "POST", "/ratings/v1/answers", @@ -41,11 +46,11 @@ def get_ratings_info_v1(self) -> RatingProfileInfo: ) return map_rating_profile(payload) - def list_reviews_v1(self, *, params: Mapping[str, object] | None = None) -> ReviewsResult: + def list_reviews_v1(self, *, query: ReviewsQuery | None = None) -> ReviewsResult: payload = self.transport.request_json( "GET", "/ratings/v1/reviews", context=RequestContext("ratings.reviews.list"), - params=params, + params=query.to_params() if query is not None else None, ) return map_reviews(payload) diff --git a/avito/ratings/domain.py b/avito/ratings/domain.py index 847d7bf..28ac355 100644 --- a/avito/ratings/domain.py +++ b/avito/ratings/domain.py @@ -2,12 +2,17 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass -from avito.core import Transport +from avito.core import Transport, ValidationError from avito.ratings.client import RatingsClient -from avito.ratings.models import JsonRequest, RatingProfileInfo, ReviewAnswerInfo, ReviewsResult +from avito.ratings.models import ( + CreateReviewAnswerRequest, + RatingProfileInfo, + ReviewAnswerInfo, + ReviewsQuery, + ReviewsResult, +) @dataclass(slots=True, frozen=True) @@ -24,8 +29,8 @@ class Review(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def list_reviews_v1(self, *, params: Mapping[str, object] | None = None) -> ReviewsResult: - return RatingsClient(self.transport).list_reviews_v1(params=params) + def list_reviews_v1(self, *, query: ReviewsQuery | None = None) -> ReviewsResult: + return RatingsClient(self.transport).list_reviews_v1(query=query) @dataclass(slots=True, frozen=True) @@ -35,8 +40,12 @@ class ReviewAnswer(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def create_review_answer_v1(self, *, payload: Mapping[str, object]) -> ReviewAnswerInfo: - return RatingsClient(self.transport).create_review_answer_v1(JsonRequest(payload)) + def create_review_answer_v1( + self, *, review_id: int, text: str + ) -> ReviewAnswerInfo: + return RatingsClient(self.transport).create_review_answer_v1( + CreateReviewAnswerRequest(review_id=review_id, text=text) + ) def delete_review_answer_v1(self, *, answer_id: int | str | None = None) -> ReviewAnswerInfo: return RatingsClient(self.transport).delete_review_answer_v1( @@ -45,7 +54,7 @@ def delete_review_answer_v1(self, *, answer_id: int | str | None = None) -> Revi def _require_answer_id(self) -> str: if self.resource_id is None: - raise ValueError("Для операции требуется `answer_id`.") + raise ValidationError("Для операции требуется `answer_id`.") return str(self.resource_id) diff --git a/avito/ratings/mappers.py b/avito/ratings/mappers.py index 1ab77a3..fc0e601 100644 --- a/avito/ratings/mappers.py +++ b/avito/ratings/mappers.py @@ -79,7 +79,7 @@ def map_review_answer(payload: object) -> ReviewAnswerInfo: answer_id=_str(data, "id"), created_at=_int(data, "createdAt"), success=_bool(data, "success"), - raw_payload=data, + _payload=data, ) @@ -93,7 +93,7 @@ def map_rating_profile(payload: object) -> RatingProfileInfo: score=_float(rating, "score"), reviews_count=_int(rating, "reviewsCount"), reviews_with_score_count=_int(rating, "reviewsWithScoreCount"), - raw_payload=data, + _payload=data, ) @@ -111,10 +111,10 @@ def map_reviews(payload: object) -> ReviewsResult: created_at=_int(item, "createdAt"), can_answer=_bool(item, "canAnswer"), used_in_score=_bool(item, "usedInScore"), - raw_payload=item, + _payload=item, ) for item in _list(data, "reviews", "items") ], total=_int(data, "total"), - raw_payload=data, + _payload=data, ) diff --git a/avito/ratings/models.py b/avito/ratings/models.py index 04487bd..8243824 100644 --- a/avito/ratings/models.py +++ b/avito/ratings/models.py @@ -5,17 +5,35 @@ from collections.abc import Mapping from dataclasses import dataclass, field +from avito.core.serialization import enable_module_serialization + @dataclass(slots=True, frozen=True) -class JsonRequest: - """Типизированная обертка над JSON payload запроса.""" +class ReviewsQuery: + """Query-параметры списка отзывов.""" + + page: int | None = None + + def to_params(self) -> dict[str, int]: + """Сериализует query-параметры списка отзывов.""" - payload: Mapping[str, object] + params: dict[str, int] = {} + if self.page is not None: + params["page"] = self.page + return params + + +@dataclass(slots=True, frozen=True) +class CreateReviewAnswerRequest: + """Запрос создания ответа на отзыв.""" + + review_id: int + text: str def to_payload(self) -> dict[str, object]: - """Сериализует payload запроса.""" + """Сериализует запрос создания ответа.""" - return dict(self.payload) + return {"reviewId": self.review_id, "text": self.text} @dataclass(slots=True, frozen=True) @@ -29,7 +47,7 @@ class ReviewInfo: created_at: int | None can_answer: bool | None used_in_score: bool | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -38,7 +56,7 @@ class ReviewsResult: items: list[ReviewInfo] total: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -48,7 +66,7 @@ class ReviewAnswerInfo: answer_id: str | None = None created_at: int | None = None success: bool | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -59,4 +77,7 @@ class RatingProfileInfo: score: float | None = None reviews_count: int | None = None reviews_with_score_count: int | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) + + +enable_module_serialization(globals()) diff --git a/avito/realty/client.py b/avito/realty/client.py index 260809f..ac93267 100644 --- a/avito/realty/client.py +++ b/avito/realty/client.py @@ -7,11 +7,12 @@ from avito.core import RequestContext, Transport from avito.realty.mappers import map_action, map_analytics_report, map_bookings, map_market_price from avito.realty.models import ( - JsonRequest, RealtyActionResult, RealtyAnalyticsInfo, + RealtyBookingsQuery, RealtyBookingsResult, RealtyMarketPriceInfo, + RealtyRequest, ) @@ -22,7 +23,7 @@ class ShortTermRentClient: transport: Transport def update_bookings_info( - self, *, user_id: int | str, item_id: int | str, request: JsonRequest + self, *, user_id: int | str, item_id: int | str, request: RealtyRequest ) -> RealtyActionResult: payload = self.transport.request_json( "POST", @@ -33,17 +34,18 @@ def update_bookings_info( return map_action(payload) def list_realty_bookings( - self, *, user_id: int | str, item_id: int | str + self, *, user_id: int | str, item_id: int | str, query: RealtyBookingsQuery ) -> RealtyBookingsResult: payload = self.transport.request_json( "GET", f"/realty/v1/accounts/{user_id}/items/{item_id}/bookings", context=RequestContext("realty.bookings.list"), + params=query.to_params(), ) return map_bookings(payload) def update_realty_prices( - self, *, user_id: int | str, item_id: int | str, request: JsonRequest + self, *, user_id: int | str, item_id: int | str, request: RealtyRequest ) -> RealtyActionResult: payload = self.transport.request_json( "POST", @@ -53,7 +55,7 @@ def update_realty_prices( ) return map_action(payload) - def get_intervals(self, request: JsonRequest) -> RealtyActionResult: + def get_intervals(self, request: RealtyRequest) -> RealtyActionResult: payload = self.transport.request_json( "POST", "/realty/v1/items/intervals", @@ -62,7 +64,9 @@ def get_intervals(self, request: JsonRequest) -> RealtyActionResult: ) return map_action(payload) - def update_base_params(self, *, item_id: int | str, request: JsonRequest) -> RealtyActionResult: + def update_base_params( + self, *, item_id: int | str, request: RealtyRequest + ) -> RealtyActionResult: payload = self.transport.request_json( "POST", f"/realty/v1/items/{item_id}/base", diff --git a/avito/realty/domain.py b/avito/realty/domain.py index db264d8..e944ba7 100644 --- a/avito/realty/domain.py +++ b/avito/realty/domain.py @@ -2,17 +2,17 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass -from avito.core import Transport +from avito.core import Transport, ValidationError from avito.realty.client import RealtyAnalyticsClient, ShortTermRentClient from avito.realty.models import ( - JsonRequest, RealtyActionResult, RealtyAnalyticsInfo, + RealtyBookingsQuery, RealtyBookingsResult, RealtyMarketPriceInfo, + RealtyRequest, ) @@ -30,20 +30,20 @@ class RealtyListing(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def get_intervals(self, *, payload: Mapping[str, object]) -> RealtyActionResult: - return ShortTermRentClient(self.transport).get_intervals(JsonRequest(payload)) + def get_intervals(self, *, request: RealtyRequest) -> RealtyActionResult: + return ShortTermRentClient(self.transport).get_intervals(request) def update_base_params( - self, *, payload: Mapping[str, object], item_id: int | str | None = None + self, *, request: RealtyRequest, item_id: int | str | None = None ) -> RealtyActionResult: return ShortTermRentClient(self.transport).update_base_params( item_id=item_id or self._require_item_id(), - request=JsonRequest(payload), + request=request, ) def _require_item_id(self) -> str: if self.resource_id is None: - raise ValueError("Для операции требуется `item_id`.") + raise ValidationError("Для операции требуется `item_id`.") return str(self.resource_id) @@ -57,35 +57,43 @@ class RealtyBooking(DomainObject): def update_bookings_info( self, *, - payload: Mapping[str, object], + request: RealtyRequest, user_id: int | str | None = None, item_id: int | str | None = None, ) -> RealtyActionResult: return ShortTermRentClient(self.transport).update_bookings_info( user_id=user_id or self._require_user_id(), item_id=item_id or self._require_item_id(), - request=JsonRequest(payload), + request=request, ) def list_realty_bookings( self, *, + date_start: str, + date_end: str, + with_unpaid: bool | None = None, user_id: int | str | None = None, item_id: int | str | None = None, ) -> RealtyBookingsResult: return ShortTermRentClient(self.transport).list_realty_bookings( user_id=user_id or self._require_user_id(), item_id=item_id or self._require_item_id(), + query=RealtyBookingsQuery( + date_start=date_start, + date_end=date_end, + with_unpaid=with_unpaid, + ), ) def _require_item_id(self) -> str: if self.resource_id is None: - raise ValueError("Для операции требуется `item_id`.") + raise ValidationError("Для операции требуется `item_id`.") return str(self.resource_id) def _require_user_id(self) -> str: if self.user_id is None: - raise ValueError("Для операции требуется `user_id`.") + raise ValidationError("Для операции требуется `user_id`.") return str(self.user_id) @@ -99,24 +107,24 @@ class RealtyPricing(DomainObject): def update_realty_prices( self, *, - payload: Mapping[str, object], + request: RealtyRequest, user_id: int | str | None = None, item_id: int | str | None = None, ) -> RealtyActionResult: return ShortTermRentClient(self.transport).update_realty_prices( user_id=user_id or self._require_user_id(), item_id=item_id or self._require_item_id(), - request=JsonRequest(payload), + request=request, ) def _require_item_id(self) -> str: if self.resource_id is None: - raise ValueError("Для операции требуется `item_id`.") + raise ValidationError("Для операции требуется `item_id`.") return str(self.resource_id) def _require_user_id(self) -> str: if self.user_id is None: - raise ValueError("Для операции требуется `user_id`.") + raise ValidationError("Для операции требуется `user_id`.") return str(self.user_id) @@ -145,7 +153,7 @@ def get_report_for_classified(self, *, item_id: int | str | None = None) -> Real def _require_item_id(self) -> str: if self.resource_id is None: - raise ValueError("Для операции требуется `item_id`.") + raise ValidationError("Для операции требуется `item_id`.") return str(self.resource_id) diff --git a/avito/realty/mappers.py b/avito/realty/mappers.py index 59931fa..cca2ab1 100644 --- a/avito/realty/mappers.py +++ b/avito/realty/mappers.py @@ -9,7 +9,9 @@ from avito.realty.models import ( RealtyActionResult, RealtyAnalyticsInfo, + RealtyBookingContact, RealtyBookingInfo, + RealtyBookingSafeDeposit, RealtyBookingsResult, RealtyMarketPriceInfo, ) @@ -66,7 +68,6 @@ def map_action(payload: object) -> RealtyActionResult: return RealtyActionResult( success=_str(data, "result") == "success" or bool(data.get("success", False)), status=_str(data, "result", "status"), - raw_payload=data, ) @@ -77,19 +78,34 @@ def map_bookings(payload: object) -> RealtyBookingsResult: return RealtyBookingsResult( items=[ RealtyBookingInfo( - booking_id=_str(item, "avito_booking_id", "id"), - status=_str(item, "status"), + booking_id=_int(item, "avito_booking_id", "id"), + base_price=_int(item, "base_price"), check_in=_str(item, "check_in"), check_out=_str(item, "check_out"), + contact=( + RealtyBookingContact( + name=_str(_mapping(item, "contact"), "name"), + email=_str(_mapping(item, "contact"), "email"), + phone=_str(_mapping(item, "contact"), "phone"), + ) + if isinstance(item.get("contact"), Mapping) + else None + ), guest_count=_int(item, "guest_count"), - base_price=_int(item, "base_price"), - guest_name=_str(_mapping(item, "contact"), "name"), - guest_email=_str(_mapping(item, "contact"), "email"), - raw_payload=item, + nights=_int(item, "nights"), + safe_deposit=( + RealtyBookingSafeDeposit( + owner_amount=_int(_mapping(item, "safe_deposit"), "owner_amount"), + tax=_int(_mapping(item, "safe_deposit"), "tax"), + total_amount=_int(_mapping(item, "safe_deposit"), "total_amount"), + ) + if isinstance(item.get("safe_deposit"), Mapping) + else None + ), + status=_str(item, "status"), ) for item in _list(data, "bookings", "items") ], - raw_payload=data, ) @@ -100,7 +116,6 @@ def map_market_price(payload: object) -> RealtyMarketPriceInfo: return RealtyMarketPriceInfo( correspondence=_str(data, "correspondence"), error_message=_str(_mapping(data, "error"), "message"), - raw_payload=data, ) @@ -115,5 +130,4 @@ def map_analytics_report(payload: object) -> RealtyAnalyticsInfo: success=bool(success_data) or _str(_mapping(data, "result"), "result") == "success", report_link=_str(success_data, "reportLink"), error_message=_str(errors, "message"), - raw_payload=data, ) diff --git a/avito/realty/models.py b/avito/realty/models.py index 90d859f..d1e558c 100644 --- a/avito/realty/models.py +++ b/avito/realty/models.py @@ -2,68 +2,103 @@ from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass + +from avito.core.serialization import SerializableModel, enable_module_serialization @dataclass(slots=True, frozen=True) -class JsonRequest: - """Типизированная обертка над JSON payload запроса.""" +class RealtyRequest: + """Унифицированный typed request для Realty API.""" - payload: Mapping[str, object] + payload: dict[str, object] def to_payload(self) -> dict[str, object]: - """Сериализует payload запроса.""" + """Сериализует JSON payload запроса.""" return dict(self.payload) @dataclass(slots=True, frozen=True) -class RealtyActionResult: +class RealtyActionResult(SerializableModel): """Результат mutation-операции по недвижимости.""" success: bool status: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class RealtyBookingInfo: +class RealtyBookingSafeDeposit(SerializableModel): + """Информация о предоплате по бронированию.""" + + owner_amount: int | None + tax: int | None + total_amount: int | None + + +@dataclass(slots=True, frozen=True) +class RealtyBookingContact(SerializableModel): + """Контактные данные гостя.""" + + name: str | None + email: str | None + phone: str | None + + +@dataclass(slots=True, frozen=True) +class RealtyBookingInfo(SerializableModel): """Информация о бронировании объекта недвижимости.""" - booking_id: str | None - status: str | None + booking_id: int | None + base_price: int | None check_in: str | None check_out: str | None + contact: RealtyBookingContact | None guest_count: int | None - base_price: int | None - guest_name: str | None - guest_email: str | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + nights: int | None + safe_deposit: RealtyBookingSafeDeposit | None + status: str | None @dataclass(slots=True, frozen=True) -class RealtyBookingsResult: +class RealtyBookingsResult(SerializableModel): """Список бронирований по объявлению.""" items: list[RealtyBookingInfo] - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class RealtyMarketPriceInfo: +class RealtyBookingsQuery: + """Query-параметры запроса бронирований.""" + + date_start: str + date_end: str + with_unpaid: bool | None = None + + def to_params(self) -> dict[str, str]: + """Сериализует query-параметры запроса бронирований.""" + + params = {"date_start": self.date_start, "date_end": self.date_end} + if self.with_unpaid is not None: + params["with_unpaid"] = "true" if self.with_unpaid else "false" + return params + + +@dataclass(slots=True, frozen=True) +class RealtyMarketPriceInfo(SerializableModel): """Соответствие цены рыночной стоимости.""" correspondence: str | None error_message: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) -class RealtyAnalyticsInfo: +class RealtyAnalyticsInfo(SerializableModel): """Информация об аналитическом отчете по недвижимости.""" success: bool report_link: str | None = None error_message: str | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + + +enable_module_serialization(globals()) diff --git a/avito/tariffs/client.py b/avito/tariffs/client.py index 4833483..f06af37 100644 --- a/avito/tariffs/client.py +++ b/avito/tariffs/client.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from avito.core import RequestContext, Transport +from avito.core.mapping import request_public_model from avito.tariffs.mappers import map_tariff_info from avito.tariffs.models import TariffInfo @@ -16,9 +17,10 @@ class TariffsClient: transport: Transport def get_tariff_info(self) -> TariffInfo: - payload = self.transport.request_json( + return request_public_model( + self.transport, "GET", "/tariff/info/1", context=RequestContext("tariffs.info.get"), + mapper=map_tariff_info, ) - return map_tariff_info(payload) diff --git a/avito/tariffs/mappers.py b/avito/tariffs/mappers.py index dbee0a4..5b4d836 100644 --- a/avito/tariffs/mappers.py +++ b/avito/tariffs/mappers.py @@ -76,7 +76,7 @@ def _map_contract(payload: Payload) -> TariffContractInfo | None: price=_float(price, "price"), original_price=_float(price, "originalPrice"), packages_count=packages_count, - raw_payload=payload, + _payload=payload, ) @@ -87,5 +87,5 @@ def map_tariff_info(payload: object) -> TariffInfo: return TariffInfo( current=_map_contract(_mapping(data, "current")), scheduled=_map_contract(_mapping(data, "scheduled")), - raw_payload=data, + _payload=data, ) diff --git a/avito/tariffs/models.py b/avito/tariffs/models.py index 6b6bbdf..5ab580b 100644 --- a/avito/tariffs/models.py +++ b/avito/tariffs/models.py @@ -5,6 +5,8 @@ from collections.abc import Mapping from dataclasses import dataclass, field +from avito.core.serialization import enable_module_serialization + @dataclass(slots=True, frozen=True) class TariffContractInfo: @@ -18,7 +20,7 @@ class TariffContractInfo: price: float | None original_price: float | None packages_count: int | None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -27,4 +29,7 @@ class TariffInfo: current: TariffContractInfo | None = None scheduled: TariffContractInfo | None = None - raw_payload: Mapping[str, object] = field(default_factory=dict) + _payload: Mapping[str, object] = field(default_factory=dict) + + +enable_module_serialization(globals()) diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index a93b8d7..0000000 --- a/docs/index.md +++ /dev/null @@ -1,100 +0,0 @@ -# Каталог API - -Описание API произведено в формате Swagger 3.0. Вы можете использовать данный файл для ознакомления с методами API, а также для генерации базового кода для работы с API на удобном для вас языке программирования с помощью утилиты Swagger Codegen или online сервиса Swagger Editor. - -## Типы авторизации - -Для использования данного API запрос должен быть авторизован. В данный момент API Авито использует следующие механизмы авторизации: - -- Персональная авторизация -- Авторизация для приложений - -## Иерархия Аккаунтов - -API для взаимодействия с иерархией аккаунтов в Авито - -## CPA-аукцион - -Методы для работы с сервисом CPA-аукционов. - -## Авторизация - -Получение и обновление авторизационных токенов для персональной авторизации и авторизации приложений - -## Автозагрузка - -Получение отчётов о публикации и редактировании объявлений через Автозагрузку - -## Автостратегия - -API для работы с автостратегией - -## Автотека - -## CallTracking[КТ] - -Методы для работы с сервисом колл-трекинга - -## CPA Авито - -Методы для работы с сервисом CPA - -## Настройка цены целевого действия - -Методы для работы с настройками цены целевого действия. - -## Доставка - -Методы API для партнеров для работы с API Логистики. - -## Объявления - -API для получение статистики по объявлениям, применения дополнительных услуг, а также просмотр статусов объявлений - -## Авито.Работа - -API для размещения, редактирования и снятия с публикации вакансии Авито Работа - -## Мессенджер - -API Мессенджера - набор методов для получения списка чатов пользователя на Авито, получения сообщений в чате, отправки сообщения в чат и др. Через API Мессенджера можно организовать интеграцию между мессенджером Авито и сторонней системой в обе стороны. В Товарах и Работе Messenger API доступен только на уровне подписки «Максимальный». В Услугах Messenger API доступен на уровнях подписки «Расширенный» и «Максимальный». - -## Управление заказами - -Методы для управления заказами на доставку. - -## Продвижение - -API для управления услугами продвижения по объявлениям. [Подробная документация](https://developers.avito.ru/api-catalog/promotion/documentation). - -## Рейтинги и отзывы - -API для работы с рейтингами и отзывами на Авито. - -## Аналитика по недвижимости - -Методы для работы с аналитикой по недвижимости - -## Рассылка скидок и спецпредложений в мессенджере (beta-version) - -API для рассылки скидок и спецпредложений покупателям, которые добавили объявления в избранное, получения информации о доступных для применения услуги объявлениях, информации об остатке количества рассылок в тарифе (если тариф подключен). - -## Управление остатками - -Методы для работы с остатками в объявлении. - -## Краткосрочная аренда - -Методы API для партнёров для работы с API краткосрочной аренды (STR) - -## Тарифы - -API для работы с тарифами в категории транспорт - -## TrxPromo - -Методы для работы с сервисом TrxPromo. - -## Информация о пользователе - -API для получения баланса кошелька пользователя, истории операций и инфорации об авторизованном пользователе diff --git a/docs/release.md b/docs/release.md deleted file mode 100644 index b311c7f..0000000 --- a/docs/release.md +++ /dev/null @@ -1,55 +0,0 @@ -# Релизная политика - -Этот документ фиксирует минимальные требования к changelog и релизному checklist перед публикацией новой версии SDK. - -## Политика changelog - -Файл `CHANGELOG.md` ведётся по принципу "что изменилось для пользователя SDK". - -Для каждой версии нужно явно фиксировать: - -- добавленные домены, методы и модели; -- breaking changes в публичном API; -- изменения в auth, retries, transport и обработке ошибок; -- изменения в документации и примерах, если они влияют на интеграцию; -- исправления регрессий и несовместимостей со swagger. - -Формат записи: - -1. Версия и дата выпуска. -2. `Добавлено` -3. `Изменено` -4. `Исправлено` -5. `Удалено`, если это применимо - -## Checklist релиза - -Перед релизом должны быть выполнены все пункты: - -1. `docs/inventory.md` синхронизирован с `docs/*.json`. -2. Все новые или изменённые публичные методы отражены в `README.md`. -3. Для новых endpoint-ов добавлены mapping/contract/regression-тесты. -4. Deprecated-методы явно помечены и не загрязняют основной API. -5. Выполнены команды: - -```bash -make check -``` - -6. Обновлён `CHANGELOG.md`. -7. Проверены docstring публичных сущностей, затронутых изменениями. -8. Проверено, что `debug_info()` не раскрывает секреты. -9. GitHub Actions workflow для Python `3.14` проходит на ветке с релизом. -10. Для публикации настроен GitHub secret `PYPI_API_TOKEN`. -11. Git tag релиза создан в формате `vX.Y.Z` и именно он определяет публикуемую версию пакета. - -## Запреты перед публикацией - -Релиз нельзя считать готовым, если выполняется хотя бы одно условие: - -- хотя бы один swagger-endpoint не сопоставлен inventory; -- публичный метод возвращает сырой JSON вместо модели SDK; -- `mypy` strict или эквивалентный профиль не проходит; -- релизная ветка не проходит CI на Python `3.14`; -- tag релиза не соответствует формату `vX.Y.Z`; -- сборка требует ручных локальных правок после запуска `poetry build`. diff --git a/tests/conftest.py b/tests/conftest.py index f96b4d8..333a5dc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,15 @@ import sys from pathlib import Path +import pytest + +from tests.fake_transport import FakeTransport + ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) + + +@pytest.fixture +def fake_transport() -> FakeTransport: + return FakeTransport() diff --git a/tests/fake_transport.py b/tests/fake_transport.py new file mode 100644 index 0000000..15ef548 --- /dev/null +++ b/tests/fake_transport.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import json +from collections import deque +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass + +import httpx + +from avito.auth import AuthSettings +from avito.config import AvitoSettings +from avito.core import Transport +from avito.core.retries import RetryPolicy +from avito.core.types import ApiTimeouts + +JsonValue = dict[str, object] | list[object] | str | int | float | bool | None + + +@dataclass(slots=True, frozen=True) +class RecordedRequest: + method: str + path: str + params: dict[str, str] + headers: dict[str, str] + json_body: JsonValue + content: bytes + + +RouteResponder = Callable[[RecordedRequest], httpx.Response] | httpx.Response + + +class FakeTransport: + """Deterministic 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]] = {} + + def add( + self, + method: str, + path: str, + *responses: RouteResponder, + ) -> FakeTransport: + 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, + ) -> FakeTransport: + return self.add( + method, + path, + httpx.Response( + status_code, + json=payload, + headers=dict(headers or {}), + ), + ) + + def build( + self, + *, + retry_policy: RetryPolicy | None = None, + user_id: int | None = None, + ) -> Transport: + settings = AvitoSettings( + base_url=self.base_url, + user_id=user_id, + auth=AuthSettings(), + retry_policy=retry_policy or RetryPolicy(), + timeouts=ApiTimeouts(), + ) + return Transport( + settings, + auth_provider=None, + client=httpx.Client(transport=httpx.MockTransport(self._handle), base_url=self.base_url), + sleep=lambda _: None, + ) + + 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 _handle(self, request: httpx.Request) -> httpx.Response: + 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(f"Unexpected request {recorded.method} {recorded.path}. Known: {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 json.loads(request.content.decode()) + except json.JSONDecodeError: + return None + + +def json_response( + payload: JsonValue, + *, + status_code: int = 200, + headers: Mapping[str, str] | None = None, +) -> httpx.Response: + return httpx.Response(status_code, json=payload, headers=dict(headers or {})) + + +def route_sequence(*responses: RouteResponder) -> Iterable[RouteResponder]: + return responses diff --git a/tests/test_calltracking_contract_alignment.py b/tests/test_calltracking_contract_alignment.py new file mode 100644 index 0000000..ed73ea5 --- /dev/null +++ b/tests/test_calltracking_contract_alignment.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import json + +import httpx + +from avito.auth import AuthSettings +from avito.config import AvitoSettings +from avito.core import Transport +from avito.core.retries import RetryPolicy +from avito.core.types import ApiTimeouts +from avito.cpa import CallTrackingCall, CallTrackingCallResponse + + +def make_transport(handler: httpx.MockTransport) -> Transport: + settings = AvitoSettings( + base_url="https://api.avito.ru", + auth=AuthSettings(), + retry_policy=RetryPolicy(), + timeouts=ApiTimeouts(), + ) + return Transport( + settings, + auth_provider=None, + client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), + sleep=lambda _: None, + ) + + +def test_calltracking_get_call_by_id_maps_call_and_error() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/calltracking/v1/getCallById/" + assert json.loads(request.content.decode()) == {"callId": 7001} + return httpx.Response( + 200, + json={ + "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": ""}, + }, + ) + + result = CallTrackingCall( + make_transport(httpx.MockTransport(handler)), + resource_id="7001", + ).get() + + assert isinstance(result, CallTrackingCallResponse) + assert result.call.call_id == "7001" + assert result.call.item_id == "9901" + assert result.error.code == 0 + assert result.to_dict() == { + "call": { + "call_id": "7001", + "item_id": "9901", + "buyer_phone": "+79990000100", + "seller_phone": "+79990000101", + "virtual_phone": "+79990000102", + "call_time": "2026-04-18T09:00:00Z", + "talk_duration": 67, + "waiting_duration": 1.25, + }, + "error": {"code": 0, "message": ""}, + } + + +def test_calltracking_get_call_by_id_preserves_error_payload() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "call": { + "callId": 7002, + "itemId": 9902, + "buyerPhone": "+79990000200", + "sellerPhone": "+79990000201", + "virtualPhone": "+79990000202", + "callTime": "2026-04-18T10:00:00Z", + "talkDuration": 33, + "waitingDuration": 0.75, + }, + "error": {"code": 409, "message": "call is archived"}, + }, + ) + + result = CallTrackingCall( + make_transport(httpx.MockTransport(handler)), + resource_id="7002", + ).get() + + assert result.error.code == 409 + assert result.error.message == "call is archived" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..9850abf --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from avito import AvitoClient +from avito.auth import AuthSettings +from avito.config import AvitoSettings +from avito.core.exceptions import ConfigurationError + +ENV_KEYS = ( + "AVITO_BASE_URL", + "BASE_URL", + "AVITO_USER_ID", + "USER_ID", + "AVITO_AUTH__CLIENT_ID", + "AVITO_AUTH__CLIENT_SECRET", + "AVITO_AUTH__REFRESH_TOKEN", + "AVITO_CLIENT_ID", + "AVITO_CLIENT_SECRET", + "AVITO_SECRET", + "CLIENT_ID", + "CLIENT_SECRET", + "SECRET", +) + + +def clear_avito_env(monkeypatch: pytest.MonkeyPatch) -> None: + for key in ENV_KEYS: + monkeypatch.delenv(key, raising=False) + + +def write_env_file(path: Path, content: str) -> Path: + path.write_text(content, encoding="utf-8") + return path + + +def test_avito_settings_from_env_reads_full_configuration( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + clear_avito_env(monkeypatch) + env_file = write_env_file( + tmp_path / ".env", + "\n".join( + ( + "AVITO_BASE_URL=https://sandbox.avito.ru", + "AVITO_USER_ID=42", + "AVITO_AUTH__CLIENT_ID=client-id", + "AVITO_AUTH__CLIENT_SECRET=client-secret", + "AVITO_AUTH__REFRESH_TOKEN=refresh-token", + ) + ), + ) + + settings = AvitoSettings.from_env(env_file=env_file) + + assert settings.base_url == "https://sandbox.avito.ru" + assert settings.user_id == 42 + assert settings.auth.client_id == "client-id" + assert settings.auth.client_secret == "client-secret" + assert settings.auth.refresh_token == "refresh-token" + + +def test_avito_settings_from_env_supports_alias_variables( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + clear_avito_env(monkeypatch) + env_file = write_env_file( + tmp_path / ".env", + "\n".join( + ( + "BASE_URL=https://file.avito.ru", + "USER_ID=77", + "CLIENT_ID=file-client-id", + "SECRET=file-client-secret", + ) + ), + ) + + settings = AvitoSettings.from_env(env_file=env_file) + + assert settings.base_url == "https://file.avito.ru" + assert settings.user_id == 77 + assert settings.auth.client_id == "file-client-id" + assert settings.auth.client_secret == "file-client-secret" + + +def test_avito_settings_from_env_requires_client_id( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + clear_avito_env(monkeypatch) + env_file = write_env_file( + tmp_path / ".env", + "AVITO_AUTH__CLIENT_SECRET=client-secret\n", + ) + + with pytest.raises(ConfigurationError, match="client_id"): + AvitoSettings.from_env(env_file=env_file) + + +def test_avito_settings_from_env_requires_client_secret( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + clear_avito_env(monkeypatch) + env_file = write_env_file( + tmp_path / ".env", + "AVITO_AUTH__CLIENT_ID=client-id\n", + ) + + with pytest.raises(ConfigurationError, match="client_secret"): + AvitoSettings.from_env(env_file=env_file) + + +def test_avito_client_from_env_initializes_client( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + clear_avito_env(monkeypatch) + env_file = write_env_file( + tmp_path / ".env", + "\n".join( + ( + "AVITO_BASE_URL=https://sandbox.avito.ru", + "AVITO_USER_ID=512", + "AVITO_AUTH__CLIENT_ID=client-id", + "AVITO_AUTH__CLIENT_SECRET=client-secret", + ) + ), + ) + + client = AvitoClient.from_env(env_file=env_file) + try: + assert client.settings.base_url == "https://sandbox.avito.ru" + assert client.settings.user_id == 512 + assert client.settings.auth.client_id == "client-id" + assert client.settings.auth.client_secret == "client-secret" + finally: + client.close() + + +def test_avito_client_requires_explicit_auth_fields() -> None: + with pytest.raises(ConfigurationError, match="client_secret"): + AvitoClient(AvitoSettings(auth=AuthSettings(client_id="client-id"))) + + +def test_explicit_settings_do_not_implicitly_read_process_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AVITO_CLIENT_SECRET", "from-process-env") + + settings = AvitoSettings(auth=AuthSettings(client_id="client-id")) + + assert settings.auth.client_secret is None + + +def test_debug_info_does_not_expose_secret_values( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + clear_avito_env(monkeypatch) + client = AvitoClient.from_env( + env_file=write_env_file( + tmp_path / ".env", + "\n".join( + ( + "AVITO_BASE_URL=https://sandbox.avito.ru", + "AVITO_USER_ID=99", + "AVITO_AUTH__CLIENT_ID=client-id", + "AVITO_AUTH__CLIENT_SECRET=super-secret", + ) + ), + ) + ) + try: + info = client.debug_info() + + assert info.base_url == "https://sandbox.avito.ru" + assert info.user_id == 99 + assert "super-secret" not in repr(info) + assert "authorization" not in repr(info).lower() + finally: + client.close() + + +def test_process_environment_overrides_dotenv_deterministically( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + clear_avito_env(monkeypatch) + env_file = write_env_file( + tmp_path / ".env", + "\n".join( + ( + "AVITO_BASE_URL=https://from-file.avito.ru", + "AVITO_AUTH__CLIENT_ID=file-client-id", + "AVITO_AUTH__CLIENT_SECRET=file-client-secret", + ) + ), + ) + monkeypatch.setenv("AVITO_BASE_URL", "https://from-env.avito.ru") + monkeypatch.setenv("AVITO_CLIENT_ID", "env-client-id") + monkeypatch.setenv("AVITO_SECRET", "env-client-secret") + + settings = AvitoSettings.from_env(env_file=env_file) + + assert settings.base_url == "https://from-env.avito.ru" + assert settings.auth.client_id == "env-client-id" + assert settings.auth.client_secret == "env-client-secret" diff --git a/tests/test_core.py b/tests/test_core.py index a3cedb9..e946477 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -10,6 +10,8 @@ from avito.config import AvitoSettings from avito.core import ( AuthenticationError, + AuthorizationError, + ConflictError, JsonPage, PaginatedList, Paginator, @@ -164,6 +166,34 @@ def test_transport_handles_rate_limit_and_classifies_errors() -> None: "POST", "/validation", context=RequestContext("validation") ) + authorization_transport = Transport( + make_settings(retry_policy=RetryPolicy(max_attempts=1)), + client=httpx.Client( + transport=httpx.MockTransport( + lambda request: httpx.Response(403, json={"message": "forbidden"}) + ), + base_url="https://api.avito.ru", + ), + sleep=lambda _: None, + ) + + with pytest.raises(AuthorizationError): + authorization_transport.request_json("GET", "/forbidden", context=RequestContext("forbidden")) + + conflict_transport = Transport( + make_settings(retry_policy=RetryPolicy(max_attempts=1)), + client=httpx.Client( + transport=httpx.MockTransport( + lambda request: httpx.Response(409, json={"message": "conflict"}) + ), + base_url="https://api.avito.ru", + ), + sleep=lambda _: None, + ) + + with pytest.raises(ConflictError): + conflict_transport.request_json("POST", "/conflict", context=RequestContext("conflict")) + def test_transport_raises_mapping_error_for_invalid_json() -> None: transport = Transport( @@ -249,19 +279,102 @@ def fetch(page: int | None, cursor: str | None) -> JsonPage[int]: items = PaginatedList(fetch, first_page=pages[1]) + assert items.loaded_count == 2 + assert items.is_materialized is False + assert items[0] == 1 assert calls == [] assert items[3] == 4 assert calls == [2] + assert items.loaded_count == 4 + assert items.is_materialized is False assert items[:] == [1, 2, 3, 4, 5] assert calls == [2, 3] + assert items.loaded_count == 5 + assert items.is_materialized is True assert len(items) == 5 assert items == [1, 2, 3, 4, 5] +def test_paginated_list_partial_iteration_fetches_only_required_pages() -> None: + calls: list[int] = [] + pages = { + 1: JsonPage(items=[1, 2], page=1, per_page=2, total=5), + 2: JsonPage(items=[3, 4], page=2, per_page=2, total=5), + 3: JsonPage(items=[5], page=3, per_page=2, total=5), + } + + def fetch(page: int | None, cursor: str | None) -> JsonPage[int]: + resolved_page = page or 1 + calls.append(resolved_page) + return pages[resolved_page] + + items = PaginatedList(fetch, first_page=pages[1]) + + assert list(item for _, item in zip(range(3), items, strict=False)) == [1, 2, 3] + assert calls == [2] + assert items.loaded_count == 4 + assert items.is_materialized is False + + +def test_paginated_list_materialize_loads_all_remaining_pages_once() -> None: + calls: list[int] = [] + pages = { + 1: JsonPage(items=[1, 2], page=1, per_page=2, total=5), + 2: JsonPage(items=[3, 4], page=2, per_page=2, total=5), + 3: JsonPage(items=[5], page=3, per_page=2, total=5), + } + + def fetch(page: int | None, cursor: str | None) -> JsonPage[int]: + resolved_page = page or 1 + calls.append(resolved_page) + return pages[resolved_page] + + items = PaginatedList(fetch, first_page=pages[1]) + + assert items.materialize() == [1, 2, 3, 4, 5] + assert items.materialize() == [1, 2, 3, 4, 5] + assert calls == [2, 3] + assert items.is_materialized is True + + +def test_paginated_list_propagates_error_when_read_reaches_failing_page() -> None: + calls: list[int] = [] + + def fetch(page: int | None, cursor: str | None) -> JsonPage[int]: + resolved_page = page or 1 + calls.append(resolved_page) + if resolved_page == 2: + raise RateLimitError("page 2 failed") + return JsonPage(items=[1, 2], page=1, per_page=2, total=4) + + items = PaginatedList(fetch, first_page=JsonPage(items=[1, 2], page=1, per_page=2, total=4)) + + assert items[0] == 1 + with pytest.raises(RateLimitError, match="page 2 failed"): + _ = items[2] + assert calls == [2] + + +def test_paginated_list_handles_empty_first_page_without_extra_calls() -> None: + calls: list[int] = [] + + def fetch(page: int | None, cursor: str | None) -> JsonPage[int]: + resolved_page = page or 1 + calls.append(resolved_page) + return JsonPage(items=[], page=resolved_page, per_page=10, total=0) + + items = PaginatedList(fetch, first_page=JsonPage(items=[], page=1, per_page=10, total=0)) + + assert items.materialize() == [] + assert calls == [] + assert items.loaded_count == 0 + assert items.is_materialized is True + + def test_transport_raises_authentication_error_after_failed_refresh() -> None: issued_tokens: Iterator[AccessToken] = iter( ( diff --git a/tests/test_facade.py b/tests/test_facade.py index cce629d..388d8d8 100644 --- a/tests/test_facade.py +++ b/tests/test_facade.py @@ -1,4 +1,4 @@ -from avito import AvitoClient +from avito import AuthSettings, AvitoClient, AvitoSettings from avito.accounts import Account, AccountHierarchy from avito.ads import Ad, AdPromotion, AdStats, AutoloadLegacy, AutoloadProfile, AutoloadReport from avito.auth import AuthProvider @@ -27,7 +27,9 @@ def test_single_client_exposes_domain_factories() -> None: - client = AvitoClient() + client = AvitoClient( + AvitoSettings(auth=AuthSettings(client_id="client-id", client_secret="client-secret")) + ) assert isinstance(client.auth(), AuthProvider) assert isinstance(client.account(1), Account) @@ -78,3 +80,7 @@ def test_single_client_exposes_domain_factories() -> None: assert isinstance(client.review_answer(1), ReviewAnswer) assert isinstance(client.rating_profile(1), RatingProfile) assert isinstance(client.tariff(1), Tariff) + + +def test_package_exports_auth_settings_as_public_config_contract() -> None: + assert AuthSettings.__name__ == "AuthSettings" diff --git a/tests/test_no_raw_payload_contract.py b/tests/test_no_raw_payload_contract.py new file mode 100644 index 0000000..9937f7f --- /dev/null +++ b/tests/test_no_raw_payload_contract.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import importlib +from dataclasses import fields, is_dataclass +from inspect import isclass + +MODEL_MODULES = ( + "avito.accounts.models", + "avito.ads.models", + "avito.autoteka.models", + "avito.cpa.models", + "avito.jobs.models", + "avito.messenger.models", + "avito.orders.models", + "avito.promotion.models", + "avito.ratings.models", + "avito.realty.models", + "avito.tariffs.models", +) + + +def iter_public_dataclasses() -> list[tuple[str, str, type[object]]]: + classes: list[tuple[str, str, type[object]]] = [] + for module_name in MODEL_MODULES: + module = importlib.import_module(module_name) + for name, value in vars(module).items(): + if not isclass(value) or getattr(value, "__module__", None) != module_name: + continue + if not is_dataclass(value): + continue + classes.append((module_name, name, value)) + return classes + + +def test_no_public_model_declares_raw_payload_field() -> None: + offenders = [] + for module_name, name, cls in iter_public_dataclasses(): + if any(field.name == "raw_payload" for field in fields(cls)): + offenders.append(f"{module_name}:{name}") + + assert offenders == [] diff --git a/tests/test_no_valueerror_in_public_surface.py b/tests/test_no_valueerror_in_public_surface.py new file mode 100644 index 0000000..599b37d --- /dev/null +++ b/tests/test_no_valueerror_in_public_surface.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from pathlib import Path + + +def test_public_domain_and_client_surface_does_not_raise_valueerror() -> None: + root = Path(__file__).resolve().parent.parent / "avito" + offenders: list[str] = [] + + for path in root.glob("*/domain.py"): + text = path.read_text(encoding="utf-8") + if "raise ValueError" in text: + offenders.append(path.as_posix()) + + for path in root.glob("*/client.py"): + text = path.read_text(encoding="utf-8") + if "raise ValueError" in text: + offenders.append(path.as_posix()) + + assert offenders == [] diff --git a/tests/test_promotion_contract_alignment.py b/tests/test_promotion_contract_alignment.py new file mode 100644 index 0000000..9b1f8f2 --- /dev/null +++ b/tests/test_promotion_contract_alignment.py @@ -0,0 +1,270 @@ +from __future__ import annotations + +import json + +import httpx +import pytest + +from avito.auth import AuthSettings +from avito.config import AvitoSettings +from avito.core import ResponseMappingError, Transport +from avito.core.retries import RetryPolicy +from avito.core.types import ApiTimeouts +from avito.promotion import ( + PromotionOrder, + TargetActionGetBidsResult, + TargetActionPricing, + TargetActionPromotionsByItemIdsResult, +) + + +def make_transport(handler: httpx.MockTransport) -> Transport: + settings = AvitoSettings( + base_url="https://api.avito.ru", + auth=AuthSettings(), + retry_policy=RetryPolicy(), + timeouts=ApiTimeouts(), + ) + return Transport( + settings, + auth_provider=None, + client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), + sleep=lambda _: None, + ) + + +def test_target_action_get_bids_maps_single_response() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/cpxpromo/1/getBids/101" + return httpx.Response( + 200, + json={ + "actionTypeID": 5, + "selectedType": "manual", + "auto": { + "budgetPenny": 10000, + "budgetType": "daily", + "minBudgetPenny": 3000, + "maxBudgetPenny": 50000, + "dailyBudget": { + "budgets": [ + { + "valuePenny": 10000, + "minForecast": 1, + "maxForecast": 3, + "compare": 7, + } + ] + }, + }, + "manual": { + "bidPenny": 1400, + "limitPenny": 15000, + "recBidPenny": 1500, + "minBidPenny": 1000, + "maxBidPenny": 2000, + "minLimitPenny": 5000, + "maxLimitPenny": 50000, + "bids": [ + { + "valuePenny": 1500, + "minForecast": 2, + "maxForecast": 5, + "compare": 10, + } + ], + }, + }, + ) + + result = TargetActionPricing(make_transport(httpx.MockTransport(handler)), resource_id=101).get_bids() + + assert isinstance(result, TargetActionGetBidsResult) + assert result.action_type_id == 5 + assert result.selected_type == "manual" + assert result.auto is not None and result.auto.daily_budget[0].budget_penny == 10000 + assert result.manual is not None and result.manual.bids[0].compare == 10 + assert result.to_dict() == { + "action_type_id": 5, + "selected_type": "manual", + "auto": { + "budget_penny": 10000, + "budget_type": "daily", + "min_budget_penny": 3000, + "max_budget_penny": 50000, + "daily_budget": [ + { + "budget_penny": 10000, + "min_forecast": 1, + "max_forecast": 3, + "compare": 7, + } + ], + "weekly_budget": [], + "monthly_budget": [], + }, + "manual": { + "bid_penny": 1400, + "limit_penny": 15000, + "rec_bid_penny": 1500, + "min_bid_penny": 1000, + "max_bid_penny": 2000, + "min_limit_penny": 5000, + "max_limit_penny": 50000, + "bids": [ + { + "value_penny": 1500, + "min_forecast": 2, + "max_forecast": 5, + "compare": 10, + } + ], + }, + } + + +def test_target_action_get_promotions_by_item_ids_maps_items_list() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/cpxpromo/1/getPromotionsByItemIds" + assert json.loads(request.content.decode()) == {"itemIDs": [101, 102]} + return httpx.Response( + 200, + json={ + "items": [ + { + "itemID": 101, + "actionTypeID": 5, + "manualPromotion": { + "bidPenny": 1400, + "limitPenny": 15000, + }, + }, + { + "itemID": 102, + "actionTypeID": 7, + "autoPromotion": { + "budgetPenny": 9000, + "budgetType": "7d", + }, + }, + ] + }, + ) + + result = TargetActionPricing( + make_transport(httpx.MockTransport(handler)), + resource_id=101, + ).get_promotions_by_item_ids(item_ids=[101, 102]) + + assert isinstance(result, TargetActionPromotionsByItemIdsResult) + assert result.items[0].manual is not None and result.items[0].manual.bid_penny == 1400 + assert result.items[1].auto is not None and result.items[1].auto.budget_type == "7d" + assert result.to_dict() == { + "items": [ + { + "item_id": 101, + "action_type_id": 5, + "auto": None, + "manual": {"bid_penny": 1400, "limit_penny": 15000}, + }, + { + "item_id": 102, + "action_type_id": 7, + "auto": {"budget_penny": 9000, "budget_type": "7d"}, + "manual": None, + }, + ] + } + + +def test_promotion_order_status_preserves_top_level_fields() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/promotion/v1/items/services/orders/status" + assert json.loads(request.content.decode()) == {"orderIds": ["ord-1"]} + return httpx.Response( + 200, + json={ + "orderId": "ord-1", + "status": "processed", + "totalPrice": 26166, + "items": [], + "errors": [], + }, + ) + + result = PromotionOrder( + make_transport(httpx.MockTransport(handler)), + resource_id="ord-1", + ).get_order_status() + + assert result.order_id == "ord-1" + assert result.status == "processed" + assert result.total_price == 26166 + assert result.to_dict() == { + "order_id": "ord-1", + "status": "processed", + "total_price": 26166, + "items": [], + "errors": [], + } + + +def test_promotion_order_status_preserves_item_price_slug_and_error_reason() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "orderId": "ord-2", + "status": "partial", + "totalPrice": 10000, + "items": [ + { + "itemId": 101, + "price": 9900, + "slug": "x2", + "status": "processed", + "errorReason": "none", + } + ], + "errors": [{"itemId": 102, "errorCode": 1005, "errorText": "Недоступно"}], + }, + ) + + result = PromotionOrder( + make_transport(httpx.MockTransport(handler)), + resource_id="ord-2", + ).get_order_status() + + assert result.items[0].item_id == 101 + assert result.items[0].price == 9900 + assert result.items[0].slug == "x2" + assert result.items[0].error_reason == "none" + assert result.errors[0].error_code == 1005 + + +@pytest.mark.parametrize( + ("path", "body"), + [ + ("/cpxpromo/1/getBids/101", {"selectedType": "manual"}), + ("/cpxpromo/1/getPromotionsByItemIds", {"items": [{"itemID": 101}]}), + ("/promotion/v1/items/services/orders/status", {"status": "processed"}), + ], +) +def test_promotion_documented_shape_raises_response_mapping_error( + path: str, + body: dict[str, object], +) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=body) + + transport = make_transport(httpx.MockTransport(handler)) + + with pytest.raises(ResponseMappingError): + if path == "/cpxpromo/1/getBids/101": + TargetActionPricing(transport, resource_id=101).get_bids() + elif path == "/cpxpromo/1/getPromotionsByItemIds": + TargetActionPricing(transport, resource_id=101).get_promotions_by_item_ids( + item_ids=[101] + ) + else: + PromotionOrder(transport, resource_id="ord-1").get_order_status() diff --git a/tests/test_public_models.py b/tests/test_public_models.py new file mode 100644 index 0000000..7e8d290 --- /dev/null +++ b/tests/test_public_models.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import json + +import httpx + +from avito.accounts import Account +from avito.accounts.models import AccountProfile +from avito.ads import Ad, AdStats +from avito.ads.models import AccountSpendings, CallStats, Listing, ListingStats, SpendingRecord +from avito.auth import AuthSettings +from avito.config import AvitoSettings +from avito.core import Transport +from avito.core.retries import RetryPolicy +from avito.core.types import ApiTimeouts +from avito.promotion import ( + BbipForecastRequestItem, + BbipPromotion, + PromotionOrder, + PromotionService, +) +from avito.promotion.models import PromotionForecast +from avito.promotion.models import PromotionOrder as PromotionOrderModel + + +def make_transport(handler: httpx.MockTransport) -> Transport: + settings = AvitoSettings( + base_url="https://api.avito.ru", + auth=AuthSettings(), + retry_policy=RetryPolicy(), + timeouts=ApiTimeouts(), + ) + return Transport( + settings, + auth_provider=None, + client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), + sleep=lambda _: None, + ) + + +def test_primary_sdk_models_serialize_without_transport_fields() -> None: + profile = AccountProfile( + id=7, + name="Иван", + email=None, + phone="+7999", + _payload={"internal": "value"}, + ) + listing = Listing( + id=101, + user_id=7, + title="Смартфон", + description=None, + status="active", + price=1000.0, + url=None, + _payload={"transport": True}, + ) + stats = ListingStats( + item_id=101, + views=42, + contacts=None, + favorites=3, + _payload={"transport": True}, + ) + calls = CallStats( + item_id=101, + calls=4, + answered_calls=3, + missed_calls=1, + _payload={"transport": True}, + ) + spendings = AccountSpendings( + items=[ + SpendingRecord( + item_id=101, + amount=77.5, + service="xl", + _payload={"transport": True}, + ) + ], + total=77.5, + _payload={"transport": True}, + ) + service = PromotionService( + item_id=101, + service_code="x2", + service_name="X2", + price=9900, + status="available", + ) + order = PromotionOrderModel( + order_id="ord-1", + item_id=101, + service_code="x2", + status="created", + created_at=None, + ) + forecast = PromotionForecast( + item_id=101, + min_views=10, + max_views=25, + total_price=7000, + total_old_price=None, + ) + + assert profile.to_dict() == {"id": 7, "name": "Иван", "email": None, "phone": "+7999"} + assert listing.to_dict() == { + "id": 101, + "user_id": 7, + "title": "Смартфон", + "description": None, + "status": "active", + "price": 1000.0, + "url": None, + } + assert stats.model_dump() == { + "item_id": 101, + "views": 42, + "contacts": None, + "favorites": 3, + } + assert calls.to_dict() == { + "item_id": 101, + "calls": 4, + "answered_calls": 3, + "missed_calls": 1, + } + assert spendings.to_dict() == { + "items": [{"item_id": 101, "amount": 77.5, "service": "xl"}], + "total": 77.5, + } + assert service.to_dict() == { + "item_id": 101, + "service_code": "x2", + "service_name": "X2", + "price": 9900, + "status": "available", + } + assert order.to_dict() == { + "order_id": "ord-1", + "item_id": 101, + "service_code": "x2", + "status": "created", + "created_at": None, + } + assert forecast.to_dict() == { + "item_id": 101, + "min_views": 10, + "max_views": 25, + "total_price": 7000, + "total_old_price": None, + } + assert isinstance(order, PromotionOrderModel) + + +def test_primary_read_methods_return_stable_sdk_models() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/core/v1/accounts/self": + return httpx.Response(200, json={"id": 7, "name": "Иван"}) + if path == "/core/v1/accounts/7/items/101/": + return httpx.Response(200, json={"id": 101, "user_id": 7, "title": "Смартфон"}) + if path == "/stats/v1/accounts/7/items": + return httpx.Response( + 200, + json={"items": [{"item_id": 101, "views": 42, "contacts": 5, "favorites": 3}]}, + ) + if path == "/core/v1/accounts/7/calls/stats/": + return httpx.Response( + 200, + json={ + "items": [{"item_id": 101, "calls": 4, "answered_calls": 3, "missed_calls": 1}] + }, + ) + if path == "/stats/v2/accounts/7/spendings": + return httpx.Response( + 200, + json={"items": [{"item_id": 101, "amount": 77.5, "service": "xl"}]}, + ) + if path == "/promotion/v1/items/services/get": + assert json.loads(request.content.decode()) == {"itemIds": [101]} + return httpx.Response( + 200, + json={ + "items": [ + { + "itemId": 101, + "serviceCode": "x2", + "serviceName": "X2", + "price": 9900, + "status": "available", + } + ] + }, + ) + if path == "/promotion/v1/items/services/orders/get": + assert json.loads(request.content.decode()) == {"itemIds": [101]} + return httpx.Response( + 200, + json={"items": [{"orderId": "ord-1", "itemId": 101, "serviceCode": "x2"}]}, + ) + assert path == "/promotion/v1/items/services/bbip/forecasts/get" + return httpx.Response( + 200, + json={"items": [{"itemId": 101, "min": 10, "max": 25, "totalPrice": 7000}]}, + ) + + transport = make_transport(httpx.MockTransport(handler)) + + profile = Account(transport, user_id=7).get_self() + listing = Ad(transport, resource_id=101, user_id=7).get() + stats = AdStats(transport, resource_id=101, user_id=7).get_item_stats() + calls = AdStats(transport, resource_id=101, user_id=7).get_calls_stats() + spendings = AdStats(transport, resource_id=101, user_id=7).get_account_spendings() + services = PromotionOrder(transport, resource_id="ord-1").list_services(item_ids=[101]) + orders = PromotionOrder(transport, resource_id="ord-1").list_orders(item_ids=[101]) + forecasts = BbipPromotion(transport, resource_id=101).get_forecasts( + items=[BbipForecastRequestItem(item_id=101, duration=7, price=1000, old_price=1200)] + ) + + assert isinstance(profile, AccountProfile) + assert isinstance(listing, Listing) + assert isinstance(stats.items[0], ListingStats) + assert isinstance(calls.items[0], CallStats) + assert isinstance(spendings, AccountSpendings) + assert isinstance(services.items[0], PromotionService) + assert isinstance(orders.items[0], PromotionOrderModel) + assert isinstance(forecasts.items[0], PromotionForecast) diff --git a/tests/test_read_contract.py b/tests/test_read_contract.py new file mode 100644 index 0000000..a084fcc --- /dev/null +++ b/tests/test_read_contract.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import json + +import httpx + +from avito.accounts import Account +from avito.accounts.models import AccountProfile +from avito.ads import Ad, AdStats, Listing +from avito.ads.models import AccountSpendings, CallStats, ListingStats +from avito.auth import AuthSettings +from avito.config import AvitoSettings +from avito.core import Transport +from avito.core.retries import RetryPolicy +from avito.core.types import ApiTimeouts + + +def make_transport(handler: httpx.MockTransport) -> Transport: + settings = AvitoSettings( + base_url="https://api.avito.ru", + auth=AuthSettings(), + retry_policy=RetryPolicy(), + timeouts=ApiTimeouts(), + ) + return Transport( + settings, + auth_provider=None, + client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), + sleep=lambda _: None, + ) + + +def test_read_methods_return_stable_sdk_models() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/core/v1/accounts/self": + return httpx.Response(200, json={"id": 7, "name": "Иван", "email": "user@example.com"}) + if path == "/core/v1/accounts/7/items/101/": + return httpx.Response(200, json={"id": 101, "user_id": 7, "title": "Смартфон"}) + if path == "/core/v1/items": + assert request.url.params["user_id"] == "7" + return httpx.Response(200, json={"items": [{"id": 101, "title": "Смартфон"}], "total": 1}) + if path == "/stats/v1/accounts/7/items": + body = json.loads(request.content.decode()) + assert body["itemIds"] == [101] + return httpx.Response( + 200, + json={"items": [{"item_id": 101, "views": 42, "contacts": 5, "favorites": 3}]}, + ) + if path == "/core/v1/accounts/7/calls/stats/": + return httpx.Response( + 200, + json={ + "items": [{"item_id": 101, "calls": 4, "answered_calls": 3, "missed_calls": 1}] + }, + ) + assert path == "/stats/v2/accounts/7/spendings" + return httpx.Response( + 200, + json={"items": [{"item_id": 101, "amount": 77.5, "service": "xl"}]}, + ) + + transport = make_transport(httpx.MockTransport(handler)) + + profile = Account(transport, user_id=7).get_self() + listing = Ad(transport, resource_id=101, user_id=7).get() + listings = Ad(transport, user_id=7).list() + item_stats = AdStats(transport, resource_id=101, user_id=7).get_item_stats() + calls_stats = AdStats(transport, resource_id=101, user_id=7).get_calls_stats() + spendings = AdStats(transport, resource_id=101, user_id=7).get_account_spendings() + + assert isinstance(profile, AccountProfile) + assert isinstance(listing, Listing) + assert isinstance(listings.items[0], Listing) + assert isinstance(item_stats.items[0], ListingStats) + assert isinstance(calls_stats.items[0], CallStats) + assert isinstance(spendings, AccountSpendings) + + +def test_read_methods_handle_empty_and_partial_upstream_payloads() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/core/v1/accounts/self": + return httpx.Response(200, json={}) + if path == "/core/v1/accounts/7/items/101/": + return httpx.Response(200, json={"id": 101}) + if path == "/core/v1/items": + return httpx.Response(200, json={}) + if path == "/stats/v1/accounts/7/items": + return httpx.Response(200, json={"items": [{"item_id": 101}]}) + if path == "/core/v1/accounts/7/calls/stats/": + return httpx.Response(200, json={}) + assert path == "/stats/v2/accounts/7/spendings" + return httpx.Response(200, json={"items": [{"item_id": 101}]}) + + transport = make_transport(httpx.MockTransport(handler)) + + profile = Account(transport, user_id=7).get_self() + listing = Ad(transport, resource_id=101, user_id=7).get() + listings = Ad(transport, user_id=7).list() + item_stats = AdStats(transport, resource_id=101, user_id=7).get_item_stats() + calls_stats = AdStats(transport, resource_id=101, user_id=7).get_calls_stats() + spendings = AdStats(transport, resource_id=101, user_id=7).get_account_spendings() + + assert profile.to_dict() == {"id": None, "name": None, "email": None, "phone": None} + assert listing.to_dict() == { + "id": 101, + "user_id": None, + "title": None, + "description": None, + "status": None, + "price": None, + "url": None, + } + assert listings.items == [] + assert item_stats.items[0].to_dict() == { + "item_id": 101, + "views": None, + "contacts": None, + "favorites": None, + } + assert calls_stats.items == [] + assert spendings.to_dict() == { + "items": [{"item_id": 101, "amount": None, "service": None}], + "total": None, + } diff --git a/tests/test_realty_contract_alignment.py b/tests/test_realty_contract_alignment.py new file mode 100644 index 0000000..3551fec --- /dev/null +++ b/tests/test_realty_contract_alignment.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import inspect + +import httpx + +from avito.auth import AuthSettings +from avito.config import AvitoSettings +from avito.core import Transport +from avito.core.retries import RetryPolicy +from avito.core.types import ApiTimeouts +from avito.realty import RealtyBooking + + +def make_transport(handler: httpx.MockTransport) -> Transport: + settings = AvitoSettings( + base_url="https://api.avito.ru", + auth=AuthSettings(), + retry_policy=RetryPolicy(), + timeouts=ApiTimeouts(), + ) + return Transport( + settings, + auth_provider=None, + client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), + sleep=lambda _: None, + ) + + +def test_realty_bookings_requires_date_start_and_date_end() -> None: + signature = inspect.signature(RealtyBooking.list_realty_bookings) + + assert signature.parameters["date_start"].default is inspect._empty + assert signature.parameters["date_end"].default is inspect._empty + + +def test_realty_bookings_sends_required_query_params() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/realty/v1/accounts/10/items/20/bookings" + assert request.url.params["date_start"] == "2026-05-01" + assert request.url.params["date_end"] == "2026-05-05" + assert request.url.params["with_unpaid"] == "true" + return httpx.Response(200, json={"bookings": []}) + + result = RealtyBooking( + make_transport(httpx.MockTransport(handler)), + resource_id="20", + user_id="10", + ).list_realty_bookings( + date_start="2026-05-01", + date_end="2026-05-05", + with_unpaid=True, + ) + + assert result.to_dict() == {"items": []} + + +def test_realty_bookings_maps_documented_fields() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "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, + }, + } + ] + }, + ) + + result = RealtyBooking( + make_transport(httpx.MockTransport(handler)), + resource_id="20", + user_id="10", + ).list_realty_bookings( + date_start="2026-05-01", + date_end="2026-05-05", + ) + + assert result.items[0].booking_id == 777 + assert result.items[0].contact is not None and result.items[0].contact.email == "ivan@example.com" + assert result.items[0].safe_deposit is not None + assert result.to_dict() == { + "items": [ + { + "booking_id": 777, + "base_price": 12000, + "check_in": "2026-05-01", + "check_out": "2026-05-05", + "contact": { + "name": "Иван", + "email": "ivan@example.com", + "phone": "9997770000", + }, + "guest_count": 2, + "nights": 4, + "safe_deposit": { + "owner_amount": 4500, + "tax": 500, + "total_amount": 5000, + }, + "status": "active", + } + ] + } diff --git a/tests/test_stage10_autoteka.py b/tests/test_stage10_autoteka.py index 98b518b..e38e353 100644 --- a/tests/test_stage10_autoteka.py +++ b/tests/test_stage10_autoteka.py @@ -12,6 +12,7 @@ AutotekaValuation, AutotekaVehicle, ) +from avito.autoteka.models import AutotekaQuery, AutotekaRequest from avito.config import AvitoSettings from avito.core import Transport from avito.core.retries import RetryPolicy @@ -140,23 +141,25 @@ def handler(request: httpx.Request) -> httpx.Response: vehicle = AutotekaVehicle(make_transport(httpx.MockTransport(handler)), resource_id="77") - catalog = vehicle.get_catalogs_resolve(payload={"brandId": 1}) - leads = vehicle.get_leads(payload={"limit": 1}) - preview_vin = vehicle.create_preview_by_vin(payload={"vin": "VIN-1"}) - preview_item = vehicle.create_preview_by_item_id(payload={"itemId": 901}) - preview_reg = vehicle.create_preview_by_reg_number(payload={"regNumber": "A123AA77"}) + catalog = vehicle.get_catalogs_resolve(request=AutotekaRequest(payload={"brandId": 1})) + leads = vehicle.get_leads(request=AutotekaRequest(payload={"limit": 1})) + preview_vin = vehicle.create_preview_by_vin(request=AutotekaRequest(payload={"vin": "VIN-1"})) + preview_item = vehicle.create_preview_by_item_id(request=AutotekaRequest(payload={"itemId": 901})) + preview_reg = vehicle.create_preview_by_reg_number( + request=AutotekaRequest(payload={"regNumber": "A123AA77"}) + ) preview_external = vehicle.create_preview_by_external_item( - payload={"itemId": "ext-1", "site": "cars.example"} + request=AutotekaRequest(payload={"itemId": "ext-1", "site": "cars.example"}) ) preview = vehicle.get_preview() specification_plate = vehicle.create_specification_by_plate_number( - payload={"plateNumber": "A123AA77"} + request=AutotekaRequest(payload={"plateNumber": "A123AA77"}) ) specification_vehicle = vehicle.create_specification_by_vehicle_id( - payload={"vehicleId": "VIN-1"} + request=AutotekaRequest(payload={"vehicleId": "VIN-1"}) ) - specification = vehicle.get_specification_get_by_id(specification_id="501") - teaser_create = vehicle.create_teaser(payload={"vehicleId": "VIN-1"}) + specification = vehicle.get_specification_by_id(specification_id="501") + teaser_create = vehicle.create_teaser(request=AutotekaRequest(payload={"vehicleId": "VIN-1"})) teaser = vehicle.get_teaser(teaser_id="601") assert catalog.items[0].values[0].label == "Audi" @@ -325,20 +328,32 @@ def handler(request: httpx.Request) -> httpx.Response: valuation = AutotekaValuation(transport) package = report.get_active_package() - created = report.create_report(payload={"previewId": 77}) - created_by_vehicle = report.create_report_by_vehicle_id(payload={"vehicleId": "VIN-1"}) + created = report.create_report(request=AutotekaRequest(payload={"previewId": 77})) + created_by_vehicle = report.create_report_by_vehicle_id( + request=AutotekaRequest(payload={"vehicleId": "VIN-1"}) + ) reports = report.list_report_list() fetched = report.get_report() - sync_reg = report.create_sync_create_report_by_reg_number(payload={"regNumber": "A123AA77"}) - sync_vin = report.create_sync_create_report_by_vin(payload={"vin": "VIN-1"}) - added = monitoring.create_monitoring_bucket_add(payload={"vehicles": ["VIN-1", "bad-vin"]}) + sync_reg = report.create_sync_report_by_reg_number( + request=AutotekaRequest(payload={"regNumber": "A123AA77"}) + ) + sync_vin = report.create_sync_report_by_vin( + request=AutotekaRequest(payload={"vin": "VIN-1"}) + ) + added = monitoring.create_monitoring_bucket_add( + request=AutotekaRequest(payload={"vehicles": ["VIN-1", "bad-vin"]}) + ) deleted = monitoring.list_monitoring_bucket_delete() - removed = monitoring.delete_monitoring_bucket_remove(payload={"vehicles": ["VIN-1"]}) - events = monitoring.get_monitoring_get_reg_actions(params={"limit": 10}) - scoring_created = scoring.create_scoring_by_vehicle_id(payload={"vehicleId": "VIN-1"}) - scoring_item = scoring.get_scoring_get_by_id() + removed = monitoring.delete_monitoring_bucket_remove( + request=AutotekaRequest(payload={"vehicles": ["VIN-1"]}) + ) + events = monitoring.get_monitoring_reg_actions(query=AutotekaQuery(params={"limit": 10})) + scoring_created = scoring.create_scoring_by_vehicle_id( + request=AutotekaRequest(payload={"vehicleId": "VIN-1"}) + ) + scoring_item = scoring.get_scoring_by_id() valuation_item = valuation.get_valuation_by_specification( - payload={"specificationId": 501, "mileage": 30000} + request=AutotekaRequest(payload={"specificationId": 501, "mileage": 30000}) ) assert package.reports_remaining == 77 diff --git a/tests/test_stage11_mock_transport_suite.py b/tests/test_stage11_mock_transport_suite.py new file mode 100644 index 0000000..0143b5f --- /dev/null +++ b/tests/test_stage11_mock_transport_suite.py @@ -0,0 +1,483 @@ +from __future__ import annotations + +import httpx +import pytest + +from avito.accounts import Account +from avito.ads import Ad, AdPromotion, AdStats +from avito.core import ( + AuthorizationError, + ConflictError, + RateLimitError, + RetryPolicy, + UnsupportedOperationError, + UpstreamApiError, + ValidationError, +) +from avito.promotion import ( + BbipForecastRequestItem, + BbipOrderItem, + BbipPromotion, + PromotionOrder, + TargetActionPricing, + TrxPromotion, +) +from avito.promotion.models import TrxPromotionApplyItem +from tests.fake_transport import FakeTransport, json_response + + +def test_mock_transport_happy_path_read_methods_and_contract_snapshots( + fake_transport: FakeTransport, +) -> None: + fake_transport.add_json( + "GET", + "/core/v1/accounts/self", + {"id": 7, "name": "Иван", "email": "ivan@example.com", "phone": "+79990000000"}, + ) + fake_transport.add( + "GET", + "/core/v1/accounts/7/items/101/", + lambda request: json_response( + { + "id": 101, + "user_id": 7, + "title": "Смартфон", + "price": 1000, + "status": "active", + "transport_debug": "ignored", + } + ), + ) + fake_transport.add( + "GET", + "/core/v1/items", + lambda request: json_response( + { + "items": { + "0": [ + {"id": 101, "title": "Смартфон", "status": "active"}, + {"id": 102, "title": "Ноутбук", "status": "active"}, + ], + "2": [{"id": 103, "title": "Планшет", "status": "draft"}], + }[request.params["offset"]], + "total": 3, + } + ), + ) + fake_transport.add_json( + "POST", + "/stats/v1/accounts/7/items", + {"items": [{"item_id": 101, "views": 45, "contacts": 5, "favorites": 2}]}, + ) + fake_transport.add_json( + "POST", + "/core/v1/accounts/7/calls/stats/", + {"items": [{"item_id": 101, "calls": 3, "answered_calls": 2, "missed_calls": 1}]}, + ) + fake_transport.add_json( + "POST", + "/stats/v2/accounts/7/spendings", + {"items": [{"item_id": 101, "amount": 77.5, "service": "x2"}]}, + ) + fake_transport.add_json( + "POST", + "/promotion/v1/items/services/get", + { + "items": [ + { + "itemId": 101, + "serviceCode": "x2", + "serviceName": "X2", + "price": 9900, + "status": "available", + "internalOnly": "ignored", + } + ] + }, + ) + fake_transport.add_json( + "POST", + "/promotion/v1/items/services/orders/get", + { + "items": [ + { + "orderId": "ord-1", + "itemId": 101, + "serviceCode": "x2", + "status": "created", + "createdAt": "2026-04-18T10:00:00+03:00", + } + ] + }, + ) + fake_transport.add_json( + "POST", + "/promotion/v1/items/services/orders/status", + {"orderId": "ord-1", "status": "processed", "items": [], "errors": []}, + ) + fake_transport.add_json( + "POST", + "/promotion/v1/items/services/bbip/forecasts/get", + {"items": [{"itemId": 101, "min": 10, "max": 25, "totalPrice": 7000, "totalOldPrice": 8400}]}, + ) + fake_transport.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}], + } + ] + }, + ) + fake_transport.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}], + }, + }, + ) + fake_transport.add_json( + "POST", + "/cpxpromo/1/getPromotionsByItemIds", + {"items": [{"itemID": 102, "actionTypeID": 7, "autoPromotion": {"budgetPenny": 9000, "budgetType": "7d"}}]}, + ) + + transport = fake_transport.build() + account = Account(transport, user_id=7) + ad = Ad(transport, resource_id=101, user_id=7) + stats = AdStats(transport, resource_id=101, user_id=7) + promotion_order = PromotionOrder(transport, resource_id="ord-1") + bbip = BbipPromotion(transport, resource_id=101) + pricing = TargetActionPricing(transport, resource_id=101) + + profile = account.get_self() + listing = ad.get() + listings = ad.list(status="active", limit=2) + item_stats = stats.get_item_stats() + call_stats = stats.get_calls_stats() + spendings = stats.get_account_spendings() + services = promotion_order.list_services(item_ids=[101]) + orders = promotion_order.list_orders(item_ids=[101]) + statuses = promotion_order.get_order_status() + forecasts = bbip.get_forecasts( + items=[BbipForecastRequestItem(item_id=101, duration=7, price=1000, old_price=1200)] + ) + suggests = bbip.get_suggests() + bids = pricing.get_bids() + promotions = pricing.get_promotions_by_item_ids(item_ids=[101, 102]) + + assert profile.to_dict() == { + "id": 7, + "name": "Иван", + "email": "ivan@example.com", + "phone": "+79990000000", + } + assert listing.to_dict() == { + "id": 101, + "user_id": 7, + "title": "Смартфон", + "description": None, + "status": "active", + "price": 1000, + "url": None, + } + assert listings.items.loaded_count == 2 + assert listings.items[0].title == "Смартфон" + assert listings.items[2].title == "Планшет" + assert fake_transport.count(method="GET", path="/core/v1/items") == 2 + assert item_stats.to_dict() == { + "items": [{"item_id": 101, "views": 45, "contacts": 5, "favorites": 2}] + } + assert call_stats.to_dict() == { + "items": [{"item_id": 101, "calls": 3, "answered_calls": 2, "missed_calls": 1}] + } + assert spendings.to_dict() == { + "items": [{"item_id": 101, "amount": 77.5, "service": "x2"}], + "total": 77.5, + } + assert services.to_dict() == { + "items": [ + { + "item_id": 101, + "service_code": "x2", + "service_name": "X2", + "price": 9900, + "status": "available", + } + ] + } + assert orders.to_dict() == { + "items": [ + { + "order_id": "ord-1", + "item_id": 101, + "service_code": "x2", + "status": "created", + "created_at": "2026-04-18T10:00:00+03:00", + } + ] + } + assert statuses.to_dict() == { + "order_id": "ord-1", + "status": "processed", + "total_price": None, + "items": [], + "errors": [], + } + assert forecasts.to_dict() == { + "items": [ + { + "item_id": 101, + "min_views": 10, + "max_views": 25, + "total_price": 7000, + "total_old_price": 8400, + } + ] + } + assert suggests.to_dict() == { + "items": [ + { + "item_id": 101, + "duration": {"start": 1, "stop": 7, "recommended": 5}, + "budgets": [{"price": 1000, "old_price": 1200, "is_recommended": True}], + } + ] + } + assert bids.to_dict() == { + "action_type_id": 5, + "selected_type": "manual", + "auto": None, + "manual": { + "bid_penny": 1400, + "limit_penny": 15000, + "rec_bid_penny": 1500, + "min_bid_penny": 1000, + "max_bid_penny": 2000, + "min_limit_penny": 5000, + "max_limit_penny": 50000, + "bids": [ + { + "value_penny": 1500, + "min_forecast": 2, + "max_forecast": 5, + "compare": None, + } + ], + }, + } + assert promotions.to_dict() == { + "items": [ + { + "item_id": 102, + "action_type_id": 7, + "auto": {"budget_penny": 9000, "budget_type": "7d"}, + "manual": None, + } + ] + } + + +def test_mock_transport_happy_path_write_methods_and_dry_run( + fake_transport: FakeTransport, +) -> None: + fake_transport.add_json( + "PUT", + "/core/v1/accounts/7/items/101/vas", + {"success": True, "status": "applied"}, + ) + fake_transport.add_json( + "PUT", + "/core/v2/accounts/7/items/101/vas_packages", + {"success": True, "status": "package_applied"}, + ) + fake_transport.add_json( + "PUT", + "/core/v2/items/101/vas/", + {"success": True, "status": "v2_applied"}, + ) + fake_transport.add_json( + "PUT", + "/promotion/v1/items/services/bbip/orders/create", + {"items": [{"itemId": 101, "success": True, "status": "created", "orderId": "ord-1"}]}, + ) + fake_transport.add_json( + "POST", + "/trx-promo/1/apply", + {"success": {"items": [{"itemID": 101, "success": True}]}}, + ) + fake_transport.add_json( + "POST", + "/trx-promo/1/cancel", + {"success": {"items": [{"itemID": 101, "success": True}]}}, + ) + fake_transport.add_json( + "POST", + "/cpxpromo/1/setAuto", + {"items": [{"itemID": 101, "success": True, "status": "auto"}]}, + ) + fake_transport.add_json( + "POST", + "/cpxpromo/1/setManual", + {"items": [{"itemID": 101, "success": True, "status": "manual"}]}, + ) + fake_transport.add_json( + "POST", + "/cpxpromo/1/remove", + {"items": [{"itemID": 101, "success": True, "status": "removed"}]}, + ) + + transport = fake_transport.build() + ad_promotion = AdPromotion(transport, resource_id=101, user_id=7) + bbip = BbipPromotion(transport, resource_id=101) + trx = TrxPromotion(transport, resource_id=101) + pricing = TargetActionPricing(transport, resource_id=101) + + previews = [ + ad_promotion.apply_vas(codes=["xl"], dry_run=True), + ad_promotion.apply_vas_package(package_code="turbo", dry_run=True), + ad_promotion.apply_vas_v2(codes=["highlight"], dry_run=True), + bbip.create_order( + items=[BbipOrderItem(item_id=101, duration=7, price=1000, old_price=1200)], + dry_run=True, + ), + trx.apply( + items=[TrxPromotionApplyItem(item_id=101, commission=1500, date_from="2026-04-18")], + dry_run=True, + ), + trx.delete(dry_run=True), + pricing.update_auto(action_type_id=5, budget_penny=8000, budget_type="7d", dry_run=True), + pricing.update_manual( + action_type_id=5, + bid_penny=1500, + limit_penny=15000, + dry_run=True, + ), + pricing.delete(dry_run=True), + ] + + assert fake_transport.requests == [] + assert [result.status for result in previews] == ["preview"] * len(previews) + + applied = [ + ad_promotion.apply_vas(codes=["xl"]), + ad_promotion.apply_vas_package(package_code="turbo"), + ad_promotion.apply_vas_v2(codes=["highlight"]), + bbip.create_order( + items=[BbipOrderItem(item_id=101, duration=7, price=1000, old_price=1200)] + ), + trx.apply( + items=[TrxPromotionApplyItem(item_id=101, commission=1500, date_from="2026-04-18")] + ), + trx.delete(), + pricing.update_auto(action_type_id=5, budget_penny=8000, budget_type="7d"), + pricing.update_manual(action_type_id=5, bid_penny=1500, limit_penny=15000), + pricing.delete(), + ] + + assert [result.request_payload for result in applied] == [ + result.request_payload for result in previews + ] + assert all(result.applied is True for result in applied) + assert fake_transport.last(method="PUT", path="/core/v1/accounts/7/items/101/vas").json_body == { + "codes": ["xl"] + } + assert fake_transport.last(method="PUT", path="/promotion/v1/items/services/bbip/orders/create").json_body == { + "items": [{"itemId": 101, "duration": 7, "price": 1000, "oldPrice": 1200}] + } + assert applied[3].to_dict() == { + "action": "create_order", + "target": {"item_ids": [101]}, + "status": "created", + "applied": True, + "request_payload": { + "items": [{"itemId": 101, "duration": 7, "price": 1000, "oldPrice": 1200}] + }, + "warnings": [], + "upstream_reference": "ord-1", + "details": { + "items": [ + {"item_id": 101, "success": True, "status": "created", "message": None} + ] + }, + } + + +@pytest.mark.parametrize( + ("status_code", "error_cls"), + [ + (400, ValidationError), + (403, AuthorizationError), + (409, ConflictError), + (429, RateLimitError), + (405, UnsupportedOperationError), + (418, UpstreamApiError), + ], +) +def test_mock_transport_maps_typed_errors_for_public_calls( + fake_transport: FakeTransport, + status_code: int, + error_cls: type[Exception], +) -> None: + fake_transport.add( + "GET", + "/core/v1/accounts/self", + httpx.Response( + status_code, + json={ + "message": "request failed", + "error": "E_TEST", + "client_secret": "super-secret", + }, + headers={"Authorization": "Bearer secret-token"}, + ), + ) + transport = fake_transport.build(retry_policy=RetryPolicy(max_attempts=1)) + + with pytest.raises(error_cls) as error: + Account(transport).get_self() + + assert "accounts.get_self" in str(error.value) + assert error.value.metadata == {"method": "GET", "path": "/core/v1/accounts/self"} + assert error.value.payload["client_secret"] == "***" + assert error.value.headers["authorization"] == "***" + + +def test_mock_transport_pagination_is_lazy_and_propagates_later_page_errors() -> None: + fake_transport = FakeTransport() + fake_transport.add( + "GET", + "/core/v1/items", + json_response( + {"items": [{"id": 101, "title": "Смартфон"}, {"id": 102, "title": "Ноутбук"}], "total": 4} + ), + httpx.Response(429, json={"message": "page 2 failed"}, headers={"retry-after": "1"}), + ) + transport = fake_transport.build(retry_policy=RetryPolicy(max_attempts=1)) + items = Ad(transport, user_id=7).list(limit=2) + + assert fake_transport.count(method="GET", path="/core/v1/items") == 1 + assert items.items[0].id == 101 + assert fake_transport.count(method="GET", path="/core/v1/items") == 1 + + with pytest.raises(RateLimitError, match="page 2 failed"): + _ = items.items[2] + + assert fake_transport.count(method="GET", path="/core/v1/items") == 2 + assert items.items[1].id == 102 + assert fake_transport.count(method="GET", path="/core/v1/items") == 2 diff --git a/tests/test_stage11_realty_ratings_tariffs.py b/tests/test_stage11_realty_ratings_tariffs.py index 38fd9ef..fd47a46 100644 --- a/tests/test_stage11_realty_ratings_tariffs.py +++ b/tests/test_stage11_realty_ratings_tariffs.py @@ -10,7 +10,9 @@ from avito.core.retries import RetryPolicy from avito.core.types import ApiTimeouts from avito.ratings import RatingProfile, Review, ReviewAnswer +from avito.ratings.models import ReviewsQuery from avito.realty import RealtyAnalyticsReport, RealtyBooking, RealtyListing, RealtyPricing +from avito.realty.models import RealtyRequest from avito.tariffs import Tariff @@ -37,6 +39,9 @@ def handler(request: httpx.Request) -> httpx.Response: assert payload == {"blockedDates": ["2026-04-18"]} return httpx.Response(200, json={"result": "success"}) if path == "/realty/v1/accounts/10/items/20/bookings": + assert request.url.params["date_start"] == "2026-05-01" + assert request.url.params["date_end"] == "2026-05-05" + assert request.url.params["with_unpaid"] == "true" return httpx.Response( 200, json={ @@ -47,8 +52,14 @@ def handler(request: httpx.Request) -> httpx.Response: "check_in": "2026-05-01", "check_out": "2026-05-05", "guest_count": 2, + "nights": 4, "base_price": 12000, - "contact": {"name": "Иван", "email": "ivan@example.com"}, + "contact": { + "name": "Иван", + "email": "ivan@example.com", + "phone": "9997770000", + }, + "safe_deposit": {"owner_amount": 4500, "tax": 500, "total_amount": 5000}, } ] }, @@ -79,20 +90,31 @@ def handler(request: httpx.Request) -> httpx.Response: listing = RealtyListing(transport, resource_id="20") analytics = RealtyAnalyticsReport(transport, resource_id="20") - updated_bookings = booking.update_bookings_info(payload={"blockedDates": ["2026-04-18"]}) - bookings = booking.list_realty_bookings() + updated_bookings = booking.update_bookings_info( + request=RealtyRequest(payload={"blockedDates": ["2026-04-18"]}) + ) + bookings = booking.list_realty_bookings( + date_start="2026-05-01", + date_end="2026-05-05", + with_unpaid=True, + ) updated_prices = pricing.update_realty_prices( - payload={"periods": [{"dateFrom": "2026-05-01", "price": 5000}]} + request=RealtyRequest(payload={"periods": [{"dateFrom": "2026-05-01", "price": 5000}]}) ) intervals = listing.get_intervals( - payload={"itemId": 20, "intervals": [{"date": "2026-05-01", "available": True}]} + request=RealtyRequest( + payload={"itemId": 20, "intervals": [{"date": "2026-05-01", "available": True}]} + ) ) - base = listing.update_base_params(payload={"minStayDays": 2}) + base = listing.update_base_params(request=RealtyRequest(payload={"minStayDays": 2})) market = analytics.get_market_price_correspondence_v1(price=5000000) report = analytics.get_report_for_classified() assert updated_bookings.success is True - assert bookings.items[0].guest_name == "Иван" + assert bookings.items[0].contact is not None + assert bookings.items[0].contact.name == "Иван" + assert bookings.items[0].contact.phone == "9997770000" + assert bookings.items[0].safe_deposit is not None assert updated_prices.status == "success" assert intervals.success is True assert base.success is True @@ -144,10 +166,10 @@ def handler(request: httpx.Request) -> httpx.Response: profile = RatingProfile(transport) review = Review(transport) - created = answer.create_review_answer_v1(payload={"reviewId": 123, "text": "Спасибо за отзыв"}) + created = answer.create_review_answer_v1(review_id=123, text="Спасибо за отзыв") deleted = answer.delete_review_answer_v1() info = profile.get_ratings_info_v1() - reviews = review.list_reviews_v1(params={"page": 2}) + reviews = review.list_reviews_v1(query=ReviewsQuery(page=2)) assert created.answer_id == "456" assert deleted.success is True diff --git a/tests/test_stage12_release_gate.py b/tests/test_stage12_release_gate.py index a8505af..05cd81f 100644 --- a/tests/test_stage12_release_gate.py +++ b/tests/test_stage12_release_gate.py @@ -18,6 +18,7 @@ def test_debug_info_does_not_expose_secrets() -> None: info = client.debug_info() assert info.base_url == "https://api.avito.ru" + assert info.user_id is None assert info.requires_auth is True assert info.retry_max_attempts == settings.retry_policy.max_attempts assert "secret" not in repr(info).lower() diff --git a/tests/test_stage4_accounts_ads.py b/tests/test_stage4_accounts_ads.py index c2e7f82..6ab8491 100644 --- a/tests/test_stage4_accounts_ads.py +++ b/tests/test_stage4_accounts_ads.py @@ -137,8 +137,25 @@ def handler(request: httpx.Request) -> httpx.Response: items = ad.list(status="active", limit=2) assert seen_offsets == ["0"] + assert items.items.loaded_count == 2 + assert items.items.is_materialized is False assert items.items[0].id == 101 + assert seen_offsets == ["0"] assert items.items[3].id == 104 + assert seen_offsets == ["0", "2"] + assert items.items[1].id == 102 + assert seen_offsets == ["0", "2"] + assert [item.title for item in items.items[:3]] == ["Смартфон", "Ноутбук", "Планшет"] + assert seen_offsets == ["0", "2"] + assert items.items.loaded_count == 4 + assert items.items.is_materialized is False + assert [item.title for item in items.items.materialize()] == [ + "Смартфон", + "Ноутбук", + "Планшет", + "Наушники", + "Камера", + ] assert len(items.items) == 5 assert [item.title for item in items.items] == [ "Смартфон", @@ -147,6 +164,7 @@ def handler(request: httpx.Request) -> httpx.Response: "Наушники", "Камера", ] + assert items.items.is_materialized is True assert seen_offsets == ["0", "2", "4"] @@ -234,8 +252,8 @@ def handler(request: httpx.Request) -> httpx.Response: assert analytics.period == "month" assert spendings.total == 77.5 assert vas_prices.items[0].code == "xl" - assert vas_apply.success is True - assert package_apply.message == "package_applied" + assert vas_apply.applied is True + assert package_apply.status == "package_applied" assert vas_v2_apply.status == "v2_applied" diff --git a/tests/test_stage4_promotion_write_contract.py b/tests/test_stage4_promotion_write_contract.py new file mode 100644 index 0000000..3530ddb --- /dev/null +++ b/tests/test_stage4_promotion_write_contract.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +import json +from collections.abc import Callable + +import httpx +import pytest + +from avito.ads import AdPromotion +from avito.auth import AuthSettings +from avito.config import AvitoSettings +from avito.core import Transport, ValidationError +from avito.core.retries import RetryPolicy +from avito.core.types import ApiTimeouts +from avito.promotion import BbipOrderItem, BbipPromotion, TargetActionPricing, TrxPromotion +from avito.promotion.models import TrxPromotionApplyItem + + +def make_transport(handler: httpx.MockTransport) -> Transport: + settings = AvitoSettings( + base_url="https://api.avito.ru", + auth=AuthSettings(), + retry_policy=RetryPolicy(), + timeouts=ApiTimeouts(), + ) + return Transport( + settings, + auth_provider=None, + client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), + sleep=lambda _: None, + ) + + +def _json(request: httpx.Request) -> dict[str, object]: + return json.loads(request.content.decode()) if request.content else {} + + +def test_write_methods_dry_run_skip_transport_and_return_preview() -> None: + calls: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request.url.path) + raise AssertionError("dry_run must not call transport") + + transport = make_transport(httpx.MockTransport(handler)) + ad_promotion = AdPromotion(transport, resource_id=101, user_id=7) + bbip = BbipPromotion(transport, resource_id=101) + trx = TrxPromotion(transport, resource_id=101) + pricing = TargetActionPricing(transport, resource_id=101) + + results = [ + ad_promotion.apply_vas(codes=["xl"], dry_run=True), + ad_promotion.apply_vas_package(package_code="turbo", dry_run=True), + ad_promotion.apply_vas_v2(codes=["highlight"], dry_run=True), + bbip.create_order( + items=[BbipOrderItem(item_id=101, duration=7, price=1000, old_price=1200)], + dry_run=True, + ), + trx.apply( + items=[TrxPromotionApplyItem(item_id=101, commission=1500, date_from="2026-04-18")], + dry_run=True, + ), + trx.delete(dry_run=True), + pricing.update_auto( + action_type_id=5, + budget_penny=8000, + budget_type="7d", + dry_run=True, + ), + pricing.update_manual( + action_type_id=5, + bid_penny=1500, + limit_penny=15000, + dry_run=True, + ), + pricing.delete(dry_run=True), + ] + + assert calls == [] + assert [result.status for result in results] == ["preview"] * len(results) + assert all(result.applied is False for result in results) + assert all(result.details == {"validated": True} for result in results) + assert all(result.request_payload for result in results) + assert results[0].to_dict() == { + "action": "apply_vas", + "target": {"item_id": 101, "user_id": 7}, + "status": "preview", + "applied": False, + "request_payload": {"codes": ["xl"]}, + "warnings": [], + "upstream_reference": None, + "details": {"validated": True}, + } + + +def test_write_methods_dry_run_and_apply_build_identical_payloads() -> None: + seen: list[tuple[str, dict[str, object]]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + payload = _json(request) + seen.append((path, payload)) + + if path == "/core/v1/accounts/7/items/101/vas": + return httpx.Response(200, json={"success": True, "status": "applied"}) + if path == "/core/v2/accounts/7/items/101/vas_packages": + return httpx.Response(200, json={"success": True, "status": "package_applied"}) + if path == "/core/v2/items/101/vas/": + return httpx.Response(200, json={"success": True, "status": "v2_applied"}) + if path == "/promotion/v1/items/services/bbip/orders/create": + return httpx.Response( + 200, + json={"items": [{"itemId": 101, "success": True, "status": "created", "orderId": "ord-1"}]}, + ) + if path == "/trx-promo/1/apply": + return httpx.Response(200, json={"success": {"items": [{"itemID": 101, "success": True}]}}) + if path == "/trx-promo/1/cancel": + return httpx.Response(200, json={"success": {"items": [{"itemID": 101, "success": True}]}}) + if path == "/cpxpromo/1/setAuto": + return httpx.Response(200, json={"items": [{"itemID": 101, "success": True, "status": "auto"}]}) + if path == "/cpxpromo/1/setManual": + return httpx.Response(200, json={"items": [{"itemID": 101, "success": True, "status": "manual"}]}) + assert path == "/cpxpromo/1/remove" + return httpx.Response(200, json={"items": [{"itemID": 101, "success": True, "status": "removed"}]}) + + transport = make_transport(httpx.MockTransport(handler)) + ad_promotion = AdPromotion(transport, resource_id=101, user_id=7) + bbip = BbipPromotion(transport, resource_id=101) + trx = TrxPromotion(transport, resource_id=101) + pricing = TargetActionPricing(transport, resource_id=101) + + vas_preview = ad_promotion.apply_vas(codes=["xl"], dry_run=True) + package_preview = ad_promotion.apply_vas_package(package_code="turbo", dry_run=True) + vas_v2_preview = ad_promotion.apply_vas_v2(codes=["highlight"], dry_run=True) + bbip_preview = bbip.create_order( + items=[BbipOrderItem(item_id=101, duration=7, price=1000, old_price=1200)], + dry_run=True, + ) + trx_apply_preview = trx.apply( + items=[TrxPromotionApplyItem(item_id=101, commission=1500, date_from="2026-04-18")], + dry_run=True, + ) + trx_delete_preview = trx.delete(dry_run=True) + auto_preview = pricing.update_auto( + action_type_id=5, + budget_penny=8000, + budget_type="7d", + dry_run=True, + ) + manual_preview = pricing.update_manual( + action_type_id=5, + bid_penny=1500, + limit_penny=15000, + dry_run=True, + ) + delete_preview = pricing.delete(dry_run=True) + + vas_apply = ad_promotion.apply_vas(codes=["xl"]) + package_apply = ad_promotion.apply_vas_package(package_code="turbo") + vas_v2_apply = ad_promotion.apply_vas_v2(codes=["highlight"]) + bbip_apply = bbip.create_order( + items=[BbipOrderItem(item_id=101, duration=7, price=1000, old_price=1200)] + ) + trx_apply = trx.apply( + items=[TrxPromotionApplyItem(item_id=101, commission=1500, date_from="2026-04-18")] + ) + trx_delete = trx.delete() + auto_apply = pricing.update_auto(action_type_id=5, budget_penny=8000, budget_type="7d") + manual_apply = pricing.update_manual(action_type_id=5, bid_penny=1500, limit_penny=15000) + delete_apply = pricing.delete() + + assert seen == [ + ("/core/v1/accounts/7/items/101/vas", vas_preview.request_payload), + ("/core/v2/accounts/7/items/101/vas_packages", package_preview.request_payload), + ("/core/v2/items/101/vas/", vas_v2_preview.request_payload), + ("/promotion/v1/items/services/bbip/orders/create", bbip_preview.request_payload), + ("/trx-promo/1/apply", trx_apply_preview.request_payload), + ("/trx-promo/1/cancel", trx_delete_preview.request_payload), + ("/cpxpromo/1/setAuto", auto_preview.request_payload), + ("/cpxpromo/1/setManual", manual_preview.request_payload), + ("/cpxpromo/1/remove", delete_preview.request_payload), + ] + assert vas_apply.request_payload == vas_preview.request_payload + assert package_apply.request_payload == package_preview.request_payload + assert vas_v2_apply.request_payload == vas_v2_preview.request_payload + assert bbip_apply.request_payload == bbip_preview.request_payload + assert trx_apply.request_payload == trx_apply_preview.request_payload + assert trx_delete.request_payload == trx_delete_preview.request_payload + assert auto_apply.request_payload == auto_preview.request_payload + assert manual_apply.request_payload == manual_preview.request_payload + assert delete_apply.request_payload == delete_preview.request_payload + assert bbip_apply.upstream_reference == "ord-1" + assert vas_apply.status == "applied" + assert auto_apply.status == "auto" + assert bbip_apply.to_dict() == { + "action": "create_order", + "target": {"item_ids": [101]}, + "status": "created", + "applied": True, + "request_payload": { + "items": [{"itemId": 101, "duration": 7, "price": 1000, "oldPrice": 1200}] + }, + "warnings": [], + "upstream_reference": "ord-1", + "details": { + "items": [ + {"item_id": 101, "success": True, "status": "created", "message": None} + ] + }, + } + + +@pytest.mark.parametrize( + ("call", "expected"), + [ + (lambda resource: resource.apply_vas(codes=[], dry_run=True), "`codes` must contain at least one item."), + ( + lambda resource: resource.apply_vas_package(package_code=" ", dry_run=True), + "`package_code` must be a non-empty string.", + ), + ( + lambda resource: resource.apply_vas(codes=["ok", " "], dry_run=True), + r"`codes\[1\]` must be a non-empty string.", + ), + ( + lambda resource: resource.update_auto( + action_type_id=5, + budget_penny=8000, + budget_type=" ", + dry_run=True, + ), + "`budget_type` must be a non-empty string.", + ), + ], +) +def test_write_validation_happens_before_transport( + call: Callable[[AdPromotion | TargetActionPricing], object], + expected: str, +) -> None: + calls: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request.url.path) + raise AssertionError("validation must fail before transport") + + transport = make_transport(httpx.MockTransport(handler)) + resource = AdPromotion(transport, resource_id=101, user_id=7) + if "budget_type" in expected: + resource = TargetActionPricing(transport, resource_id=101) + + with pytest.raises(ValidationError, match=expected): + call(resource) + + assert calls == [] + + +@pytest.mark.parametrize( + ("call", "expected"), + [ + ( + lambda resource: resource.create_order( + items=[BbipOrderItem(item_id=0, duration=7, price=1000, old_price=1200)], + dry_run=True, + ), + r"`items\[0\]\.item_id` must be a positive integer.", + ), + ( + lambda resource: resource.create_order( + items=[BbipOrderItem(item_id=101, duration=0, price=1000, old_price=1200)], + dry_run=True, + ), + r"`items\[0\]\.duration` must be a positive integer.", + ), + ( + lambda resource: resource.apply( + items=[TrxPromotionApplyItem(item_id=101, commission=0, date_from="2026-04-18")], + dry_run=True, + ), + r"`items\[0\]\.commission` must be a positive integer.", + ), + ( + lambda resource: resource.apply( + items=[TrxPromotionApplyItem(item_id=101, commission=100, date_from=" ")], + dry_run=True, + ), + r"`items\[0\]\.date_from` must be a non-empty string.", + ), + ], +) +def test_nested_write_validation_happens_before_transport( + call: Callable[[BbipPromotion | TrxPromotion], object], + expected: str, +) -> None: + calls: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request.url.path) + raise AssertionError("validation must fail before transport") + + transport = make_transport(httpx.MockTransport(handler)) + resource: BbipPromotion | TrxPromotion + if "date_from" in expected or "commission" in expected: + resource = TrxPromotion(transport, resource_id=101) + else: + resource = BbipPromotion(transport, resource_id=101) + + with pytest.raises(ValidationError, match=expected): + call(resource) + + assert calls == [] + + +def test_write_upstream_validation_error_is_mapped_to_sdk_error() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/cpxpromo/1/setManual" + return httpx.Response(422, json={"message": "invalid bid"}) + + transport = make_transport(httpx.MockTransport(handler)) + pricing = TargetActionPricing(transport, resource_id=101) + + with pytest.raises(ValidationError, match="invalid bid"): + pricing.update_manual(action_type_id=5, bid_penny=1500, limit_penny=15000) diff --git a/tests/test_stage5_promotion_read_contract.py b/tests/test_stage5_promotion_read_contract.py new file mode 100644 index 0000000..2507371 --- /dev/null +++ b/tests/test_stage5_promotion_read_contract.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import json + +import httpx +import pytest + +from avito.auth import AuthSettings +from avito.config import AvitoSettings +from avito.core import ResponseMappingError, Transport +from avito.core.retries import RetryPolicy +from avito.core.types import ApiTimeouts +from avito.promotion import ( + PromotionOrder, + PromotionOrderStatusResult, + TargetActionGetBidsResult, + TargetActionPricing, + TargetActionPromotion, +) + + +def make_transport(handler: httpx.MockTransport) -> Transport: + settings = AvitoSettings( + base_url="https://api.avito.ru", + auth=AuthSettings(), + retry_policy=RetryPolicy(), + timeouts=ApiTimeouts(), + ) + return Transport( + settings, + auth_provider=None, + client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), + sleep=lambda _: None, + ) + + +def test_promotion_read_methods_return_documented_models() -> 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 == "/promotion/v1/items/services/orders/status": + assert payload == {"orderIds": ["ord-1"]} + return httpx.Response( + 200, + json={ + "orderId": "ord-1", + "status": "processed", + "totalPrice": 26166, + "items": [ + { + "itemId": 101, + "price": 9900, + "slug": "x2", + "status": "processed", + "errorReason": None, + } + ], + "errors": [{"itemId": 102, "errorCode": 1005, "errorText": "Недоступно"}], + }, + ) + if path == "/cpxpromo/1/getBids/101": + return httpx.Response( + 200, + json={ + "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, + "compare": 10, + } + ], + }, + }, + ) + assert path == "/cpxpromo/1/getPromotionsByItemIds" + assert payload == {"itemIDs": [101, 102]} + return httpx.Response( + 200, + json={ + "items": [ + { + "itemID": 102, + "actionTypeID": 7, + "autoPromotion": {"budgetPenny": 9000, "budgetType": "7d"}, + } + ] + }, + ) + + transport = make_transport(httpx.MockTransport(handler)) + + status = PromotionOrder(transport, resource_id="ord-1").get_order_status() + bids = TargetActionPricing(transport, resource_id=101).get_bids() + promotions = TargetActionPricing(transport, resource_id=101).get_promotions_by_item_ids( + item_ids=[101, 102] + ) + + assert isinstance(status, PromotionOrderStatusResult) + assert isinstance(bids, TargetActionGetBidsResult) + assert isinstance(promotions.items[0], TargetActionPromotion) + + assert status.to_dict() == { + "order_id": "ord-1", + "status": "processed", + "total_price": 26166, + "items": [ + { + "item_id": 101, + "price": 9900, + "slug": "x2", + "status": "processed", + "error_reason": None, + } + ], + "errors": [{"item_id": 102, "error_code": 1005, "error_text": "Недоступно"}], + } + assert bids.to_dict()["manual"]["bids"][0]["compare"] == 10 + assert promotions.to_dict() == { + "items": [ + { + "item_id": 102, + "action_type_id": 7, + "auto": {"budget_penny": 9000, "budget_type": "7d"}, + "manual": None, + } + ] + } + + +@pytest.mark.parametrize( + ("path", "body"), + [ + ("/promotion/v1/items/services/orders/status", {"items": []}), + ("/cpxpromo/1/getBids/102", {"items": []}), + ("/cpxpromo/1/getPromotionsByItemIds", {"items": [{"itemID": 102}]}), + ], +) +def test_documented_read_mappers_raise_on_invalid_shape(path: str, body: dict[str, object]) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=body) + + transport = make_transport(httpx.MockTransport(handler)) + + with pytest.raises(ResponseMappingError): + if path == "/promotion/v1/items/services/orders/status": + PromotionOrder(transport, resource_id="ord-2").get_order_status() + elif path == "/cpxpromo/1/getBids/102": + TargetActionPricing(transport, resource_id=102).get_bids() + else: + TargetActionPricing(transport, resource_id=102).get_promotions_by_item_ids( + item_ids=[102] + ) diff --git a/tests/test_stage6_error_model.py b/tests/test_stage6_error_model.py new file mode 100644 index 0000000..3968370 --- /dev/null +++ b/tests/test_stage6_error_model.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import httpx +import pytest + +from avito.auth import AuthSettings +from avito.config import AvitoSettings +from avito.core import ( + AuthorizationError, + ConflictError, + RateLimitError, + RequestContext, + ServerError, + Transport, + UnsupportedOperationError, + UpstreamApiError, + ValidationError, +) +from avito.core.retries import RetryPolicy +from avito.core.types import ApiTimeouts + + +def make_transport(handler: httpx.MockTransport) -> Transport: + settings = AvitoSettings( + base_url="https://api.avito.ru", + auth=AuthSettings(client_id="client-id", client_secret="client-secret"), + retry_policy=RetryPolicy(max_attempts=1), + timeouts=ApiTimeouts(), + ) + return Transport( + settings, + auth_provider=None, + client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), + sleep=lambda _: None, + ) + + +@pytest.mark.parametrize( + ("status_code", "error_cls"), + [ + (400, ValidationError), + (422, ValidationError), + (401, AuthorizationError), + (403, AuthorizationError), + (409, ConflictError), + (429, RateLimitError), + (405, UnsupportedOperationError), + (418, UpstreamApiError), + (500, ServerError), + ], +) +def test_transport_maps_http_statuses_to_typed_sdk_errors( + status_code: int, + error_cls: type[Exception], +) -> None: + transport = make_transport( + httpx.MockTransport( + lambda request: httpx.Response( + status_code, + json={ + "message": "boom", + "code": "E_GENERIC", + "access_token": "secret-token", + }, + headers={"Authorization": "Bearer secret-token"}, + ) + ) + ) + + with pytest.raises(error_cls) as error: + transport.request_json( + "POST", + "/typed-errors", + context=RequestContext("promotion.target_action.update_manual_bid"), + ) + + assert str(error.value).find("operation=promotion.target_action.update_manual_bid") != -1 + assert error.value.status_code == status_code + assert error.value.error_code == "E_GENERIC" + assert error.value.metadata == {"method": "POST", "path": "/typed-errors"} + assert "secret-token" not in str(error.value.payload) + assert "secret-token" not in str(error.value.headers) + + +def test_transport_unknown_upstream_error_keeps_safe_context() -> None: + transport = make_transport( + httpx.MockTransport( + lambda request: httpx.Response( + 418, + json={ + "detail": "teapot", + "client_secret": "top-secret", + "nested": {"refresh_token": "hidden"}, + }, + ) + ) + ) + + with pytest.raises(UpstreamApiError) as error: + transport.request_json("GET", "/teapot", context=RequestContext("ads.list_items")) + + assert error.value.operation == "ads.list_items" + assert error.value.metadata == {"method": "GET", "path": "/teapot"} + assert error.value.payload == { + "detail": "teapot", + "client_secret": "***", + "nested": {"refresh_token": "***"}, + } + assert "top-secret" not in str(error.value) + + +def test_authorization_error_is_raised_for_auth_failures() -> None: + transport = make_transport( + httpx.MockTransport(lambda request: httpx.Response(401, json={"message": "unauthorized"})) + ) + + with pytest.raises(AuthorizationError, match="unauthorized") as error: + transport.request_json("GET", "/secure", context=RequestContext("accounts.get_self")) + + assert error.value.operation == "accounts.get_self" diff --git a/tests/test_stage6_promotion.py b/tests/test_stage6_promotion.py index d7dd17c..57b0e65 100644 --- a/tests/test_stage6_promotion.py +++ b/tests/test_stage6_promotion.py @@ -21,6 +21,12 @@ TrxPromotion, TrxPromotionApplyItem, ) +from avito.promotion.models import ( + CreateAutostrategyBudgetRequest, + CreateAutostrategyCampaignRequest, + ListAutostrategyCampaignsRequest, + UpdateAutostrategyCampaignRequest, +) def make_transport(handler: httpx.MockTransport) -> Transport: @@ -77,7 +83,10 @@ def handler(request: httpx.Request) -> httpx.Response: ) assert path == "/promotion/v1/items/services/orders/status" assert payload == {"orderIds": ["ord-1"]} - return httpx.Response(200, json={"items": [{"orderId": "ord-1", "status": "processed"}]}) + return httpx.Response( + 200, + json={"orderId": "ord-1", "status": "processed", "items": [], "errors": []}, + ) promotion = PromotionOrder(make_transport(httpx.MockTransport(handler)), resource_id="ord-1") @@ -89,7 +98,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert dictionary.items[0].code == "x2" assert services.items[0].price == 9900 assert orders.items[0].order_id == "ord-1" - assert statuses.items[0].status == "processed" + assert statuses.status == "processed" def test_bbip_and_trxpromo_flows() -> None: @@ -188,10 +197,11 @@ def handler(request: httpx.Request) -> httpx.Response: commissions = trx.get_commissions() assert forecasts.items[0].max_views == 25 - assert order_result.items[0].status == "created" + assert order_result.status == "created" + assert order_result.applied is True assert suggests.items[0].duration is not None and suggests.items[0].duration.recommended == 5 - assert applied.items[0].success is True - assert cancelled.items[0].success is True + assert applied.applied is True + assert cancelled.applied is True assert commissions.items[0].valid_commission_range is not None assert commissions.items[0].valid_commission_range.value_max == 2000 @@ -223,21 +233,25 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={ - "items": [ - { - "itemID": 101, - "actionTypeID": 5, - "bidPenny": 1400, - "availableBids": [ - { - "valuePenny": 1500, - "minForecast": 2, - "maxForecast": 5, - "compare": 10, - } - ], - } - ] + "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, + "compare": 10, + } + ], + }, }, ) if path == "/cpxpromo/1/getPromotionsByItemIds": @@ -249,9 +263,7 @@ def handler(request: httpx.Request) -> httpx.Response: { "itemID": 102, "actionTypeID": 7, - "budgetPenny": 9000, - "budgetType": "7d", - "isAuto": True, + "autoPromotion": {"budgetPenny": 9000, "budgetType": "7d"}, } ] }, @@ -290,12 +302,12 @@ def handler(request: httpx.Request) -> httpx.Response: manual = pricing.update_manual(action_type_id=5, bid_penny=1500, limit_penny=15000) assert bids.items[0].available_prices[0].price_penny == 1200 - assert saved.items[0].success is True - assert details.items[0].available_bids[0].compare == 10 - assert promotions.items[0].is_auto is True - assert removed.items[0].status == "removed" - assert auto.items[0].status == "auto" - assert manual.items[0].status == "manual" + assert saved.applied is True + assert details.manual is not None and details.manual.bids[0].compare == 10 + assert promotions.items[0].auto is not None + assert removed.status == "removed" + assert auto.status == "auto" + assert manual.status == "manual" def test_autostrategy_flows() -> None: @@ -375,12 +387,16 @@ def handler(request: httpx.Request) -> httpx.Response: campaign = AutostrategyCampaign(make_transport(httpx.MockTransport(handler)), resource_id=77) - budget = campaign.create_budget(payload={"listingFee": 1000}) - created = campaign.create(payload={"title": "Весенняя кампания", "budgetId": "budget-1"}) - updated = campaign.update(payload={"campaignId": 77, "title": "Обновленная кампания"}) + budget = campaign.create_budget(request=CreateAutostrategyBudgetRequest(payload={"listingFee": 1000})) + created = campaign.create( + request=CreateAutostrategyCampaignRequest(payload={"title": "Весенняя кампания", "budgetId": "budget-1"}) + ) + updated = campaign.update( + request=UpdateAutostrategyCampaignRequest(payload={"campaignId": 77, "title": "Обновленная кампания"}) + ) info = campaign.get() stopped = campaign.delete() - campaigns = campaign.list(payload={"status": "active"}) + campaigns = campaign.list(request=ListAutostrategyCampaignsRequest(payload={"status": "active"})) stat = campaign.get_stat() assert budget.budget_id == "budget-1" diff --git a/tests/test_stage7_orders.py b/tests/test_stage7_orders.py index 92663e2..8874458 100644 --- a/tests/test_stage7_orders.py +++ b/tests/test_stage7_orders.py @@ -10,6 +10,7 @@ from avito.core.retries import RetryPolicy from avito.core.types import ApiTimeouts from avito.orders import DeliveryOrder, DeliveryTask, Order, OrderLabel, SandboxDelivery, Stock +from avito.orders.models import OrdersRequest def make_transport(handler: httpx.MockTransport) -> Transport: @@ -89,16 +90,22 @@ def handler(request: httpx.Request) -> httpx.Response: order = Order(make_transport(httpx.MockTransport(handler)), resource_id="ord-1") orders = order.list() - marked = order.update_markings(payload={"orderId": "ord-1", "codes": ["abc"]}) - applied = order.apply(payload={"orderId": "ord-1", "transition": "confirm"}) - code_checked = order.check_confirmation_code(payload={"orderId": "ord-1", "code": "1234"}) - cnc = order.set_cnc_details(payload={"orderId": "ord-1", "pickupPointId": "pvz-1"}) + marked = order.update_markings(request=OrdersRequest(payload={"orderId": "ord-1", "codes": ["abc"]})) + applied = order.apply(request=OrdersRequest(payload={"orderId": "ord-1", "transition": "confirm"})) + code_checked = order.check_confirmation_code( + request=OrdersRequest(payload={"orderId": "ord-1", "code": "1234"}) + ) + cnc = order.set_cnc_details(request=OrdersRequest(payload={"orderId": "ord-1", "pickupPointId": "pvz-1"})) courier_ranges = order.get_courier_delivery_range() courier_set = order.set_courier_delivery_range( - payload={"orderId": "ord-1", "intervalId": "int-1"} + request=OrdersRequest(payload={"orderId": "ord-1", "intervalId": "int-1"}) + ) + tracking = order.update_tracking_number( + request=OrdersRequest(payload={"orderId": "ord-1", "trackingNumber": "TRK-1"}) + ) + returned = order.accept_return_order( + request=OrdersRequest(payload={"orderId": "ord-1", "postalOfficeId": "ops-1"}) ) - tracking = order.update_tracking_number(payload={"orderId": "ord-1", "trackingNumber": "TRK-1"}) - returned = order.accept_return_order(payload={"orderId": "ord-1", "postalOfficeId": "ops-1"}) assert orders.items[0].buyer_name == "Иван" assert marked.status == "marked" @@ -130,7 +137,7 @@ def handler(request: httpx.Request) -> httpx.Response: label = OrderLabel(make_transport(httpx.MockTransport(handler)), resource_id="42") - task = label.create(payload={"orderIds": ["ord-1"]}) + task = label.create(request=OrdersRequest(payload={"orderIds": ["ord-1"]})) pdf = label.download() assert task.task_id == "42" @@ -194,16 +201,25 @@ def handler(request: httpx.Request) -> httpx.Response: sandbox = SandboxDelivery(transport, resource_id="sand-1") task = DeliveryTask(transport, resource_id="51") - announcement = delivery.create_announcement(payload={"orderId": "ord-1"}) - parcel = delivery.create(payload={"orderId": "ord-1", "parcelId": "par-1"}) - cancelled = delivery.delete(payload={"orderId": "ord-1"}) - callback = delivery.create_change_parcel_result(payload={"parcelId": "par-1", "result": "ok"}) - changed = delivery.update_change_parcels(payload={"parcelIds": ["par-1"]}) - sandbox_announcement = sandbox.create_announcement(payload={"orderId": "sand-1"}) - tracked = sandbox.track_announcement(payload={"orderId": "sand-1"}) + announcement = delivery.create_announcement(request=OrdersRequest(payload={"orderId": "ord-1"})) + parcel = delivery.create(request=OrdersRequest(payload={"orderId": "ord-1", "parcelId": "par-1"})) + cancelled = delivery.delete(request=OrdersRequest(payload={"orderId": "ord-1"})) + callback = delivery.create_change_parcel_result( + request=OrdersRequest(payload={"parcelId": "par-1", "result": "ok"}) + ) + changed = delivery.update_change_parcels(request=OrdersRequest(payload={"parcelIds": ["par-1"]})) + sandbox_announcement = sandbox.create_announcement( + request=OrdersRequest(payload={"orderId": "sand-1"}) + ) + tracked = sandbox.track_announcement(request=OrdersRequest(payload={"orderId": "sand-1"})) centers = sandbox.list_sorting_center() - added_areas = sandbox.add_areas(tariff_id="tf-1", payload={"areas": [{"city": "Москва"}]}) - sandbox_parcel = sandbox.create_parcel(payload={"orderId": "sand-1", "parcelId": "spar-1"}) + added_areas = sandbox.add_areas( + tariff_id="tf-1", + request=OrdersRequest(payload={"areas": [{"city": "Москва"}]}), + ) + sandbox_parcel = sandbox.create_parcel( + request=OrdersRequest(payload={"orderId": "sand-1", "parcelId": "spar-1"}) + ) task_info = task.get() assert announcement.task_id == "11" @@ -253,8 +269,8 @@ def handler(request: httpx.Request) -> httpx.Response: stock = Stock(make_transport(httpx.MockTransport(handler)), resource_id="123321") - info = stock.get(payload={"itemIds": [123321]}) - updated = stock.update(payload={"stocks": [{"item_id": 123321, "quantity": 7}]}) + info = stock.get(request=OrdersRequest(payload={"itemIds": [123321]})) + updated = stock.update(request=OrdersRequest(payload={"stocks": [{"item_id": 123321, "quantity": 7}]})) assert info.items[0].quantity == 5 assert updated.items[0].external_id == "AB123456" diff --git a/tests/test_stage8_jobs.py b/tests/test_stage8_jobs.py index fe7e44e..b6a652b 100644 --- a/tests/test_stage8_jobs.py +++ b/tests/test_stage8_jobs.py @@ -10,6 +10,7 @@ from avito.core.retries import RetryPolicy from avito.core.types import ApiTimeouts from avito.jobs import Application, JobDictionary, JobWebhook, Resume, Vacancy +from avito.jobs.models import JobsQuery, JobsRequest def make_transport(handler: httpx.MockTransport) -> Transport: @@ -88,13 +89,13 @@ def handler(request: httpx.Request) -> httpx.Response: application = Application(transport, resource_id="app-1") webhook = JobWebhook(transport) - ids = application.list(params={"updatedAtFrom": "2026-04-18"}) - applications = application.list(payload={"ids": ["app-1"]}) + ids = application.list(query=JobsQuery(params={"updatedAtFrom": "2026-04-18"})) + applications = application.list(request=JobsRequest(payload={"ids": ["app-1"]})) states = application.get_states() - viewed = application.update(payload={"applies": [{"id": "app-1", "is_viewed": True}]}) - applied = application.apply(payload={"ids": ["app-1"], "action": "invited"}) + viewed = application.update(request=JobsRequest(payload={"applies": [{"id": "app-1", "is_viewed": True}]})) + applied = application.apply(request=JobsRequest(payload={"ids": ["app-1"], "action": "invited"})) current_hook = webhook.get() - updated_hook = webhook.update(payload={"url": "https://example.com/job"}) + updated_hook = webhook.update(request=JobsRequest(payload={"url": "https://example.com/job"})) deleted_hook = webhook.delete(url="https://example.com/job") hooks = webhook.list() @@ -146,7 +147,7 @@ def handler(request: httpx.Request) -> httpx.Response: resume = Resume(make_transport(httpx.MockTransport(handler)), resource_id="res-1") - results = resume.list(params={"query": "оператор"}) + results = resume.list(query=JobsQuery(params={"query": "оператор"})) contacts = resume.get_contacts() item = resume.get() @@ -225,20 +226,26 @@ def handler(request: httpx.Request) -> httpx.Response: vacancy = Vacancy(make_transport(httpx.MockTransport(handler)), resource_id="101") - created_v1 = vacancy.create(payload={"title": "Продавец"}, version=1) - updated_v1 = vacancy.update(payload={"title": "Старший продавец"}, version=1) - archived_v1 = vacancy.delete(payload={"employee_id": 7}) - prolonged_v1 = vacancy.prolongate(payload={"billing_type": "package"}) + created_v1 = vacancy.create(request=JobsRequest(payload={"title": "Продавец"}), version=1) + updated_v1 = vacancy.update( + request=JobsRequest(payload={"title": "Старший продавец"}), + version=1, + ) + archived_v1 = vacancy.delete(request=JobsRequest(payload={"employee_id": 7})) + prolonged_v1 = vacancy.prolongate(request=JobsRequest(payload={"billing_type": "package"})) list_v2 = vacancy.list() - created_v2 = vacancy.create(payload={"title": "Вакансия v2"}) - batch_v2 = vacancy.get_by_ids(payload={"ids": [101]}) - statuses_v2 = vacancy.get_statuses(payload={"ids": [101]}) + created_v2 = vacancy.create(request=JobsRequest(payload={"title": "Вакансия v2"})) + batch_v2 = vacancy.get_by_ids(request=JobsRequest(payload={"ids": [101]})) + statuses_v2 = vacancy.get_statuses(request=JobsRequest(payload={"ids": [101]})) updated_v2 = vacancy.update( - payload={"title": "Вакансия v2 updated"}, version=2, vacancy_uuid="vac-uuid-1" + request=JobsRequest(payload={"title": "Вакансия v2 updated"}), + version=2, + vacancy_uuid="vac-uuid-1", ) item_v2 = vacancy.get() auto_renewal = vacancy.update_auto_renewal( - payload={"auto_renewal": True}, vacancy_uuid="vac-uuid-1" + request=JobsRequest(payload={"auto_renewal": True}), + vacancy_uuid="vac-uuid-1", ) assert created_v1.id == "101" diff --git a/tests/test_stage8_serialization_contract.py b/tests/test_stage8_serialization_contract.py new file mode 100644 index 0000000..75e4c75 --- /dev/null +++ b/tests/test_stage8_serialization_contract.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import importlib +import json +from dataclasses import is_dataclass +from inspect import isclass + +from avito.autoteka.models import CatalogField, CatalogFieldValue, CatalogResolveResult +from avito.core.types import BinaryResponse +from avito.cpa.models import CallTrackingRecord, CpaAudioRecord +from avito.messenger.models import SendMessageRequest +from avito.orders.models import LabelPdfResult +from avito.tariffs.models import TariffContractInfo, TariffInfo + +MODEL_MODULES = ( + "avito.accounts.models", + "avito.ads.models", + "avito.autoteka.models", + "avito.cpa.models", + "avito.jobs.models", + "avito.messenger.models", + "avito.orders.models", + "avito.promotion.models", + "avito.ratings.models", + "avito.realty.models", + "avito.tariffs.models", +) + + +def iter_model_classes() -> list[tuple[str, str, type[object]]]: + classes: list[tuple[str, str, type[object]]] = [] + for module_name in MODEL_MODULES: + module = importlib.import_module(module_name) + for name, value in vars(module).items(): + if not isclass(value) or getattr(value, "__module__", None) != module_name: + continue + if not is_dataclass(value): + continue + classes.append((module_name, name, value)) + return classes + + +def test_all_model_dataclasses_expose_standard_serialization_methods() -> None: + missing = [ + f"{module_name}:{name}" + for module_name, name, cls in iter_model_classes() + if not hasattr(cls, "to_dict") or not hasattr(cls, "model_dump") + ] + + assert missing == [] + + +def test_recursive_serialization_is_json_compatible_and_hides_transport_fields() -> None: + tariff = TariffInfo( + current=TariffContractInfo( + level="Максимальный", + is_active=True, + start_time=1713427200, + close_time=None, + bonus=10, + price=1990, + original_price=2490, + packages_count=2, + _payload={"transport": True}, + ), + scheduled=None, + _payload={"transport": True}, + ) + catalog = CatalogResolveResult( + items=[ + CatalogField( + field_id="brand", + label="Марка", + data_type="integer", + values=[ + CatalogFieldValue( + value_id="1", + label="Audi", + _payload={"transport": True}, + ) + ], + _payload={"transport": True}, + ) + ], + _payload={"transport": True}, + ) + request = SendMessageRequest(message="hello") + + assert tariff.to_dict() == { + "current": { + "level": "Максимальный", + "is_active": True, + "start_time": 1713427200, + "close_time": None, + "bonus": 10, + "price": 1990, + "original_price": 2490, + "packages_count": 2, + }, + "scheduled": None, + } + assert catalog.model_dump() == { + "items": [ + { + "field_id": "brand", + "label": "Марка", + "data_type": "integer", + "values": [{"value_id": "1", "label": "Audi"}], + } + ] + } + assert request.to_dict() == {"message": "hello", "type": None} + + json.dumps(tariff.to_dict()) + json.dumps(catalog.to_dict()) + json.dumps(request.to_dict()) + + +def test_binary_result_models_serialize_without_transport_objects() -> None: + response = BinaryResponse( + content=b"\x00\x01payload", + content_type="application/octet-stream", + filename="artifact.bin", + status_code=200, + headers={"x-test": "1"}, + ) + + pdf = LabelPdfResult(binary=response) + audio = CpaAudioRecord(binary=response) + tracking = CallTrackingRecord(binary=response) + + expected = { + "filename": "artifact.bin", + "content_type": "application/octet-stream", + "content_base64": "AAFwYXlsb2Fk", + } + + assert pdf.to_dict() == expected + assert audio.model_dump() == expected + assert tracking.to_dict() == expected + + assert "binary" not in pdf.to_dict() + assert "status_code" not in pdf.to_dict() + assert "headers" not in pdf.to_dict() + + json.dumps(pdf.to_dict()) diff --git a/tests/test_stage9_cpa.py b/tests/test_stage9_cpa.py index 06103e3..403ba59 100644 --- a/tests/test_stage9_cpa.py +++ b/tests/test_stage9_cpa.py @@ -10,6 +10,15 @@ from avito.core.retries import RetryPolicy from avito.core.types import ApiTimeouts from avito.cpa import CallTrackingCall, CpaCall, CpaChat, CpaLead, CpaLegacy +from avito.cpa.models import ( + CallTrackingCallsRequest, + CpaCallByIdRequest, + CpaCallComplaintRequest, + CpaCallsByTimeRequest, + CpaChatsByTimeRequest, + CpaLeadComplaintRequest, + CpaPhonesFromChatsRequest, +) def make_transport(handler: httpx.MockTransport) -> Transport: @@ -108,9 +117,16 @@ def handler(request: httpx.Request) -> httpx.Response: chat = CpaChat(make_transport(httpx.MockTransport(handler)), resource_id="act-1") item = chat.get() - chats_v1 = chat.list(payload={"createdAtFrom": "2026-04-18T00:00:00+03:00"}, version=1) - chats_v2 = chat.list(payload={"createdAtFrom": "2026-04-18T00:00:00+03:00", "limit": 10}) - phones = chat.get_phones_info_from_chats(payload={"actionIds": ["act-1", "act-2"]}) + chats_v1 = chat.list( + request=CpaChatsByTimeRequest(created_at_from="2026-04-18T00:00:00+03:00"), + version=1, + ) + chats_v2 = chat.list( + request=CpaChatsByTimeRequest(created_at_from="2026-04-18T00:00:00+03:00", limit=10) + ) + phones = chat.get_phones_info_from_chats( + request=CpaPhonesFromChatsRequest(action_ids=["act-1", "act-2"]) + ) assert item.chat_id == "chat-1" assert item.item_title == "Велосипед" @@ -201,19 +217,21 @@ def handler(request: httpx.Request) -> httpx.Response: legacy = CpaLegacy(transport, resource_id="2001") calls = cpa_call.list( - payload={ - "dateTimeFrom": "2026-04-18T00:00:00+03:00", - "dateTimeTo": "2026-04-18T23:59:59+03:00", - } + request=CpaCallsByTimeRequest( + date_time_from="2026-04-18T00:00:00+03:00", + date_time_to="2026-04-18T23:59:59+03:00", + ) + ) + complaint = cpa_call.create_complaint( + request=CpaCallComplaintRequest(call_id=2001, reason="spam") ) - complaint = cpa_call.create_create_complaint(payload={"callId": 2001, "reason": "spam"}) complaint_by_action = cpa_lead.create_complaint_by_action_id( - payload={"actionId": "act-1", "reason": "duplicate"} + request=CpaLeadComplaintRequest(action_id="act-1", reason="duplicate") ) balance_v3 = cpa_lead.create_balance_info_v3() - balance_v2 = legacy.legacy_create_balance_info_v2() - call_v2 = legacy.legacy_create_call_by_id_v2(payload={"callId": 2001}) - record = legacy.legacy_get_call() + balance_v2 = legacy.get_balance_info_v2() + call_v2 = legacy.get_call_by_id_v2(request=CpaCallByIdRequest(call_id=2001)) + record = legacy.get_call() assert calls.items[0].record_url == "https://example.com/record-2001.mp3" assert complaint.success is True @@ -231,7 +249,7 @@ def test_calltracking_flows() -> None: def handler(request: httpx.Request) -> httpx.Response: path = request.url.path if path == "/calltracking/v1/getCallById/": - assert json.loads(request.content.decode()) == {"callId": "7001"} + assert json.loads(request.content.decode()) == {"callId": 7001} return httpx.Response( 200, json={ @@ -288,17 +306,18 @@ def handler(request: httpx.Request) -> httpx.Response: item = call.get() items = call.list( - payload={ - "dateTimeFrom": "2026-04-01T00:00:00Z", - "dateTimeTo": "2026-04-18T23:59:59Z", - "limit": 100, - "offset": 0, - } + request=CallTrackingCallsRequest( + date_time_from="2026-04-01T00:00:00Z", + date_time_to="2026-04-18T23:59:59Z", + limit=100, + offset=0, + ) ) record = call.download() - assert item.call_id == "7001" - assert item.talk_duration == 67 + assert item.call.call_id == "7001" + assert item.call.talk_duration == 67 + assert item.error.code == 0 assert items.items[0].buyer_phone == "+79990000100" assert record.filename == "record-7001.wav" assert record.binary.content == audio_bytes diff --git a/tests/test_stage9_transport_isolation.py b/tests/test_stage9_transport_isolation.py new file mode 100644 index 0000000..33f967b --- /dev/null +++ b/tests/test_stage9_transport_isolation.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import inspect +import json + +import httpx + +from avito.accounts import Account, AccountProfile +from avito.accounts.mappers import map_account_profile +from avito.ads import Ad, Listing +from avito.ads.mappers import map_ad_item +from avito.auth import AuthSettings +from avito.config import AvitoSettings +from avito.core import Transport +from avito.core.retries import RetryPolicy +from avito.core.types import ApiTimeouts +from avito.promotion import BbipPromotion, PromotionOrder, PromotionService, PromotionServicesResult +from avito.promotion.mappers import map_promotion_services + + +def make_transport(handler: httpx.MockTransport) -> Transport: + settings = AvitoSettings( + base_url="https://api.avito.ru", + auth=AuthSettings(), + retry_policy=RetryPolicy(), + timeouts=ApiTimeouts(), + ) + return Transport( + settings, + auth_provider=None, + client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), + sleep=lambda _: None, + ) + + +def test_public_packages_do_not_export_transport_shapes_or_mappers() -> None: + import avito.accounts as accounts + import avito.ads as ads + import avito.cpa as cpa + import avito.jobs as jobs + import avito.orders as orders + import avito.promotion as promotion + + for module in (accounts, ads, cpa, jobs, orders, promotion): + exported_names = set(getattr(module, "__all__", ())) + assert "JsonRequest" not in exported_names + assert "Transport" not in exported_names + assert not any(name.startswith("map_") for name in exported_names) + assert not any(name.endswith("Client") for name in exported_names) + + +def test_public_domain_signatures_hide_internal_request_wrappers() -> None: + methods = ( + Account.get_self, + Ad.get, + Ad.list, + PromotionOrder.list_services, + PromotionOrder.list_orders, + PromotionOrder.get_order_status, + BbipPromotion.get_suggests, + ) + banned_tokens = ("JsonRequest", "CreateBbip", "ListPromotion", "GetPromotionOrderStatusRequest") + + for method in methods: + signature_text = str(inspect.signature(method)) + doc_text = inspect.getdoc(method) or "" + public_text = f"{signature_text}\n{doc_text}" + for token in banned_tokens: + assert token not in public_text + + +def test_public_methods_return_sdk_models_not_transport_shapes() -> 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 == "/core/v1/accounts/self": + return httpx.Response(200, json={"user_id": 7, "title": "Shop 7"}) + if path == "/core/v1/accounts/7/items/101/": + return httpx.Response( + 200, + json={ + "item_id": 101, + "userId": 7, + "title": "Ноутбук", + "price": 55000, + "link": "https://example.test/items/101", + }, + ) + assert path == "/promotion/v1/items/services/get" + assert payload == {"itemIds": [101]} + return httpx.Response( + 200, + json={ + "services": [ + { + "itemID": 101, + "code": "xl", + "name": "XL", + "pricePenny": 12900, + "status": "available", + } + ] + }, + ) + + transport = make_transport(httpx.MockTransport(handler)) + + profile = Account(transport, user_id=7).get_self() + listing = Ad(transport, resource_id=101, user_id=7).get() + services = PromotionOrder(transport).list_services(item_ids=[101]) + + assert isinstance(profile, AccountProfile) + assert isinstance(listing, Listing) + assert isinstance(services, PromotionServicesResult) + assert isinstance(services.items[0], PromotionService) + + assert profile.to_dict() == {"id": 7, "name": "Shop 7", "email": None, "phone": None} + assert listing.to_dict() == { + "id": 101, + "user_id": 7, + "title": "Ноутбук", + "description": None, + "status": None, + "price": 55000.0, + "url": "https://example.test/items/101", + } + assert services.to_dict() == { + "items": [ + { + "item_id": 101, + "service_code": "xl", + "service_name": "XL", + "price": 12900, + "status": "available", + } + ] + } + assert "_payload" not in profile.to_dict() + assert "_payload" not in listing.to_dict() + assert "_payload" not in services.to_dict() + + +def test_mappers_keep_stable_contract_for_happy_and_partial_payloads() -> None: + happy_profile = map_account_profile({"id": 7, "name": "Main shop", "email": "shop@example.test"}) + partial_profile = map_account_profile({"user_id": 7, "title": "Main shop"}) + + happy_listing = map_ad_item( + { + "id": 101, + "user_id": 7, + "title": "Phone", + "description": "Flagship", + "status": "active", + "price": 99990, + "url": "https://example.test/items/101", + } + ) + partial_listing = map_ad_item({"itemId": 101, "userId": 7, "title": "Phone"}) + + happy_services = map_promotion_services( + { + "items": [ + { + "itemId": 101, + "serviceCode": "highlight", + "serviceName": "Highlight", + "price": 4900, + "status": "active", + } + ] + } + ) + partial_services = map_promotion_services({"services": [{"itemID": 101, "code": "highlight"}]}) + + assert happy_profile.to_dict() == { + "id": 7, + "name": "Main shop", + "email": "shop@example.test", + "phone": None, + } + assert partial_profile.to_dict() == { + "id": 7, + "name": "Main shop", + "email": None, + "phone": None, + } + + assert happy_listing.to_dict() == { + "id": 101, + "user_id": 7, + "title": "Phone", + "description": "Flagship", + "status": "active", + "price": 99990.0, + "url": "https://example.test/items/101", + } + assert partial_listing.to_dict() == { + "id": 101, + "user_id": 7, + "title": "Phone", + "description": None, + "status": None, + "price": None, + "url": None, + } + + assert happy_services.to_dict() == { + "items": [ + { + "item_id": 101, + "service_code": "highlight", + "service_name": "Highlight", + "price": 4900, + "status": "active", + } + ] + } + assert partial_services.to_dict() == { + "items": [ + { + "item_id": 101, + "service_code": "highlight", + "service_name": None, + "price": None, + "status": None, + } + ] + } diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a5bc514 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" From 68148124df9c502e807dfd43a9575ba95fad9e50 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Sun, 19 Apr 2026 00:53:08 +0300 Subject: [PATCH 02/17] =?UTF-8?q?=D0=A1=D0=BB=D0=B5=D0=B4=D1=83=D1=8E?= =?UTF-8?q?=D1=89=D0=B8=D0=B9=20=D1=84=D1=80=D0=B0=D0=B3=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 + README.md | 38 +++- avito/autoteka/__init__.py | 26 +++ avito/autoteka/client.py | 72 +++++--- avito/autoteka/domain.py | 51 +++-- avito/autoteka/models.py | 157 +++++++++++++++- avito/jobs/__init__.py | 28 +++ avito/jobs/client.py | 76 ++++++-- avito/jobs/domain.py | 49 +++-- avito/jobs/models.py | 171 +++++++++++++++-- avito/messenger/__init__.py | 4 + avito/messenger/client.py | 5 +- avito/messenger/domain.py | 7 +- avito/messenger/models.py | 28 +++ avito/orders/__init__.py | 34 ++++ avito/orders/client.py | 90 ++++++--- avito/orders/domain.py | 53 ++++-- avito/orders/models.py | 184 ++++++++++++++++++- avito/realty/__init__.py | 14 ++ avito/realty/client.py | 13 +- avito/realty/domain.py | 13 +- avito/realty/models.py | 88 +++++++-- tests/test_public_api_shape.py | 68 +++++++ tests/test_stage10_autoteka.py | 54 +++--- tests/test_stage11_realty_ratings_tariffs.py | 22 ++- tests/test_stage5_messenger.py | 12 +- tests/test_stage7_orders.py | 64 +++++-- tests/test_stage8_jobs.py | 52 ++++-- 28 files changed, 1235 insertions(+), 242 deletions(-) create mode 100644 tests/test_public_api_shape.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 07de466..7a8605c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,3 +26,7 @@ - `pyproject.toml` дополнен strict-конфигурацией `mypy`, правилами `ruff` и современным build backend `poetry-core`. - `Makefile` разделён на `fmt`, `lint`, `typecheck`, `test`, `build` и `check`, чтобы release не зависел от автоформатирования. - `AGENTS.md` синхронизирован с реальной структурой SDK, наличием тестов и Python `3.14` quality gate. +- `realty` больше не использует generic `RealtyRequest`: публичные методы принимают `RealtyBookingsUpdateRequest`, `RealtyPricesUpdateRequest`, `RealtyIntervalsRequest` и `RealtyBaseParamsUpdateRequest`. +- `jobs` больше не использует generic `JobsRequest` / `JobsQuery`: публичный surface переведен на отдельные typed request/query-модели для applications, vacancies, resumes и webhooks. +- `autoteka` больше не использует generic `AutotekaRequest` / `AutotekaQuery`: публичный surface переведен на отдельные typed request/query-модели для preview, report, monitoring, scoring и valuation сценариев. +- `messenger.ChatMedia.upload_images()` больше не принимает `dict[str, object]`; вместо него используется typed request через `UploadImageFile` / `UploadImagesRequest`. diff --git a/README.md b/README.md index f96f307..ffe7620 100644 --- a/README.md +++ b/README.md @@ -116,10 +116,21 @@ with AvitoClient() as avito: ```python from avito import AvitoClient +from avito.messenger import UploadImageFile with AvitoClient() as avito: chats = avito.chat(user_id=123).list() message = avito.chat(chat_id="chat-1", user_id=123).send_message(message="Здравствуйте") + uploaded = avito.chat_media(user_id=123).upload_images( + files=[ + UploadImageFile( + field_name="image", + filename="photo.jpg", + content=b"...", + content_type="image/jpeg", + ) + ] + ) subscriptions = avito.chat_webhook().list() ``` @@ -149,10 +160,14 @@ with AvitoClient() as avito: ```python from avito import AvitoClient +from avito.jobs import ApplicationIdsQuery, ResumeSearchQuery with AvitoClient() as avito: vacancies = avito.vacancy().list() - applications = avito.application().list() + applications = avito.application().list( + query=ApplicationIdsQuery(updated_at_from="2026-04-18") + ) + resumes = avito.resume().list(query=ResumeSearchQuery(query="оператор")) webhooks = avito.job_webhook().list() ``` @@ -194,19 +209,34 @@ with AvitoClient() as avito: ```python from avito import AvitoClient +from avito.autoteka import PreviewReportRequest, VinRequest with AvitoClient() as avito: - preview = avito.autoteka_vehicle().create_preview_by_vin(payload={"vin": "XTA00000000000000"}) - report = avito.autoteka_report(report_id=preview.preview_id).get_report() + preview = avito.autoteka_vehicle().create_preview_by_vin( + request=VinRequest(vin="XTA00000000000000") + ) + report = avito.autoteka_report().create_report( + request=PreviewReportRequest(preview_id=int(preview.preview_id or 0)) + ) ``` ### Недвижимость, отзывы и тарифы ```python from avito import AvitoClient +from avito.realty import RealtyBookingsUpdateRequest, RealtyPricePeriod, RealtyPricesUpdateRequest with AvitoClient() as avito: - bookings = avito.realty_booking().list() + booking = avito.realty_booking(item_id=20, user_id=10) + booking.update_bookings_info( + request=RealtyBookingsUpdateRequest(blocked_dates=["2026-05-01"]) + ) + bookings = booking.list_realty_bookings(date_start="2026-05-01", date_end="2026-05-05") + avito.realty_pricing(item_id=20, user_id=10).update_realty_prices( + request=RealtyPricesUpdateRequest( + periods=[RealtyPricePeriod(date_from="2026-05-01", price=5000)] + ) + ) reviews = avito.review().list_reviews_v1() tariff = avito.tariff().get_tariff_info() ``` diff --git a/avito/autoteka/__init__.py b/avito/autoteka/__init__.py index c8ec887..4cd82af 100644 --- a/avito/autoteka/__init__.py +++ b/avito/autoteka/__init__.py @@ -22,10 +22,23 @@ CatalogField, CatalogFieldValue, CatalogResolveResult, + CatalogResolveRequest, + ExternalItemPreviewRequest, + ItemIdRequest, + LeadsRequest, + MonitoringBucketRequest, MonitoringBucketResult, MonitoringEvent, + MonitoringEventsQuery, MonitoringEventsResult, MonitoringInvalidVehicle, + PlateNumberRequest, + PreviewReportRequest, + RegNumberRequest, + TeaserCreateRequest, + ValuationBySpecificationRequest, + VehicleIdRequest, + VinRequest, ) __all__ = ( @@ -47,9 +60,22 @@ "CatalogField", "CatalogFieldValue", "CatalogResolveResult", + "CatalogResolveRequest", "DomainObject", + "ExternalItemPreviewRequest", + "ItemIdRequest", + "LeadsRequest", + "MonitoringBucketRequest", "MonitoringBucketResult", "MonitoringEvent", + "MonitoringEventsQuery", "MonitoringEventsResult", "MonitoringInvalidVehicle", + "PlateNumberRequest", + "PreviewReportRequest", + "RegNumberRequest", + "TeaserCreateRequest", + "ValuationBySpecificationRequest", + "VehicleIdRequest", + "VinRequest", ) diff --git a/avito/autoteka/client.py b/avito/autoteka/client.py index b8875fd..360eb2b 100644 --- a/avito/autoteka/client.py +++ b/avito/autoteka/client.py @@ -22,17 +22,28 @@ AutotekaLeadsResult, AutotekaPackageInfo, AutotekaPreviewInfo, - AutotekaQuery, AutotekaReportInfo, AutotekaReportsResult, - AutotekaRequest, AutotekaScoringInfo, AutotekaSpecificationInfo, AutotekaTeaserInfo, AutotekaValuationInfo, CatalogResolveResult, + CatalogResolveRequest, + ExternalItemPreviewRequest, + ItemIdRequest, + LeadsRequest, + MonitoringBucketRequest, + MonitoringEventsQuery, MonitoringBucketResult, MonitoringEventsResult, + PlateNumberRequest, + PreviewReportRequest, + RegNumberRequest, + TeaserCreateRequest, + ValuationBySpecificationRequest, + VehicleIdRequest, + VinRequest, ) from avito.core import RequestContext, Transport @@ -60,7 +71,7 @@ def _context(self, operation_name: str, *, allow_retry: bool = False) -> Request class CatalogClient(AutotekaBaseClient): """Выполняет HTTP-операции автокаталога.""" - def get_catalogs_resolve(self, request: AutotekaRequest) -> CatalogResolveResult: + def get_catalogs_resolve(self, request: CatalogResolveRequest) -> CatalogResolveResult: payload = self.transport.request_json( "POST", "/autoteka/v1/catalogs/resolve", @@ -74,7 +85,7 @@ def get_catalogs_resolve(self, request: AutotekaRequest) -> CatalogResolveResult class LeadsClient(AutotekaBaseClient): """Выполняет HTTP-операции сервиса Сигнал.""" - def get_leads(self, request: AutotekaRequest) -> AutotekaLeadsResult: + def get_leads(self, request: LeadsRequest) -> AutotekaLeadsResult: payload = self.transport.request_json( "POST", "/autoteka/v1/get-leads/", @@ -88,26 +99,26 @@ def get_leads(self, request: AutotekaRequest) -> AutotekaLeadsResult: class PreviewClient(AutotekaBaseClient): """Выполняет HTTP-операции превью автомобиля.""" - def create_by_vin(self, request: AutotekaRequest) -> AutotekaPreviewInfo: + def create_by_vin(self, request: VinRequest) -> AutotekaPreviewInfo: return self._post_preview( "/autoteka/v1/previews", "autoteka.preview.create_by_vin", request ) - def create_by_external_item(self, request: AutotekaRequest) -> AutotekaPreviewInfo: + def create_by_external_item(self, request: ExternalItemPreviewRequest) -> AutotekaPreviewInfo: return self._post_preview( "/autoteka/v1/request-preview-by-external-item", "autoteka.preview.create_by_external_item", request, ) - def create_by_item_id(self, request: AutotekaRequest) -> AutotekaPreviewInfo: + def create_by_item_id(self, request: ItemIdRequest) -> AutotekaPreviewInfo: return self._post_preview( "/autoteka/v1/request-preview-by-item-id", "autoteka.preview.create_by_item_id", request, ) - def create_by_reg_number(self, request: AutotekaRequest) -> AutotekaPreviewInfo: + def create_by_reg_number(self, request: RegNumberRequest) -> AutotekaPreviewInfo: return self._post_preview( "/autoteka/v1/request-preview-by-regnumber", "autoteka.preview.create_by_reg_number", @@ -122,7 +133,12 @@ def get_preview(self, *, preview_id: int | str) -> AutotekaPreviewInfo: ) return map_preview(payload) - def _post_preview(self, path: str, operation: str, request: AutotekaRequest) -> AutotekaPreviewInfo: + def _post_preview( + self, + path: str, + operation: str, + request: VinRequest | ExternalItemPreviewRequest | ItemIdRequest | RegNumberRequest, + ) -> AutotekaPreviewInfo: payload = self.transport.request_json( "POST", path, @@ -144,10 +160,10 @@ def get_active_package(self) -> AutotekaPackageInfo: ) return map_package(payload) - def create_report(self, request: AutotekaRequest) -> AutotekaReportInfo: + def create_report(self, request: PreviewReportRequest) -> AutotekaReportInfo: return self._post_report("/autoteka/v1/reports", "autoteka.report.create", request) - def create_report_by_vehicle_id(self, request: AutotekaRequest) -> AutotekaReportInfo: + def create_report_by_vehicle_id(self, request: VehicleIdRequest) -> AutotekaReportInfo: return self._post_report( "/autoteka/v1/reports-by-vehicle-id", "autoteka.report.create_by_vehicle_id", @@ -170,21 +186,26 @@ def get_report(self, *, report_id: int | str) -> AutotekaReportInfo: ) return map_report(payload) - def create_sync_report_by_reg_number(self, request: AutotekaRequest) -> AutotekaReportInfo: + def create_sync_report_by_reg_number(self, request: RegNumberRequest) -> AutotekaReportInfo: return self._post_report( "/autoteka/v1/sync/create-by-regnumber", "autoteka.report.create_sync_by_reg_number", request, ) - def create_sync_report_by_vin(self, request: AutotekaRequest) -> AutotekaReportInfo: + def create_sync_report_by_vin(self, request: VinRequest) -> AutotekaReportInfo: return self._post_report( "/autoteka/v1/sync/create-by-vin", "autoteka.report.create_sync_by_vin", request, ) - def _post_report(self, path: str, operation: str, request: AutotekaRequest) -> AutotekaReportInfo: + def _post_report( + self, + path: str, + operation: str, + request: PreviewReportRequest | VehicleIdRequest | RegNumberRequest | VinRequest, + ) -> AutotekaReportInfo: payload = self.transport.request_json( "POST", path, @@ -198,7 +219,7 @@ def _post_report(self, path: str, operation: str, request: AutotekaRequest) -> A class MonitoringClient(AutotekaBaseClient): """Выполняет HTTP-операции мониторинга.""" - def add_bucket(self, request: AutotekaRequest) -> MonitoringBucketResult: + def add_bucket(self, request: MonitoringBucketRequest) -> MonitoringBucketResult: return self._post_bucket( "/autoteka/v1/monitoring/bucket/add", "autoteka.monitoring.bucket_add", @@ -213,7 +234,7 @@ def delete_bucket(self) -> MonitoringBucketResult: ) return map_monitoring_bucket(payload) - def remove_bucket(self, request: AutotekaRequest) -> MonitoringBucketResult: + def remove_bucket(self, request: MonitoringBucketRequest) -> MonitoringBucketResult: return self._post_bucket( "/autoteka/v1/monitoring/bucket/remove", "autoteka.monitoring.bucket_remove", @@ -221,7 +242,7 @@ def remove_bucket(self, request: AutotekaRequest) -> MonitoringBucketResult: ) def get_reg_actions( - self, *, query: AutotekaQuery | None = None + self, *, query: MonitoringEventsQuery | None = None ) -> MonitoringEventsResult: payload = self.transport.request_json( "GET", @@ -232,7 +253,10 @@ def get_reg_actions( return map_monitoring_events(payload) def _post_bucket( - self, path: str, operation: str, request: AutotekaRequest + self, + path: str, + operation: str, + request: MonitoringBucketRequest, ) -> MonitoringBucketResult: payload = self.transport.request_json( "POST", @@ -247,7 +271,7 @@ def _post_bucket( class ScoringClient(AutotekaBaseClient): """Выполняет HTTP-операции скоринга рисков.""" - def create_by_vehicle_id(self, request: AutotekaRequest) -> AutotekaScoringInfo: + def create_by_vehicle_id(self, request: VehicleIdRequest) -> AutotekaScoringInfo: payload = self.transport.request_json( "POST", "/autoteka/v1/scoring/by-vehicle-id", @@ -269,14 +293,14 @@ def get_by_id(self, *, scoring_id: int | str) -> AutotekaScoringInfo: class SpecificationsClient(AutotekaBaseClient): """Выполняет HTTP-операции спецификаций автомобиля.""" - def create_by_plate_number(self, request: AutotekaRequest) -> AutotekaSpecificationInfo: + def create_by_plate_number(self, request: PlateNumberRequest) -> AutotekaSpecificationInfo: return self._post_specification( "/autoteka/v1/specifications/by-plate-number", "autoteka.specification.create_by_plate_number", request, ) - def create_by_vehicle_id(self, request: AutotekaRequest) -> AutotekaSpecificationInfo: + def create_by_vehicle_id(self, request: VehicleIdRequest) -> AutotekaSpecificationInfo: return self._post_specification( "/autoteka/v1/specifications/by-vehicle-id", "autoteka.specification.create_by_vehicle_id", @@ -295,7 +319,7 @@ def _post_specification( self, path: str, operation: str, - request: AutotekaRequest, + request: PlateNumberRequest | VehicleIdRequest, ) -> AutotekaSpecificationInfo: payload = self.transport.request_json( "POST", @@ -310,7 +334,7 @@ def _post_specification( class TeaserClient(AutotekaBaseClient): """Выполняет HTTP-операции тизеров.""" - def create(self, request: AutotekaRequest) -> AutotekaTeaserInfo: + def create(self, request: TeaserCreateRequest) -> AutotekaTeaserInfo: payload = self.transport.request_json( "POST", "/autoteka/v1/teasers", @@ -332,7 +356,7 @@ def get(self, *, teaser_id: int | str) -> AutotekaTeaserInfo: class ValuationClient(AutotekaBaseClient): """Выполняет HTTP-операции оценки стоимости.""" - def get_by_specification(self, request: AutotekaRequest) -> AutotekaValuationInfo: + def get_by_specification(self, request: ValuationBySpecificationRequest) -> AutotekaValuationInfo: payload = self.transport.request_json( "POST", "/autoteka/v1/valuation/by-specification", diff --git a/avito/autoteka/domain.py b/avito/autoteka/domain.py index c3178bf..58c13d1 100644 --- a/avito/autoteka/domain.py +++ b/avito/autoteka/domain.py @@ -19,17 +19,28 @@ AutotekaLeadsResult, AutotekaPackageInfo, AutotekaPreviewInfo, - AutotekaQuery, AutotekaReportInfo, AutotekaReportsResult, - AutotekaRequest, AutotekaScoringInfo, AutotekaSpecificationInfo, AutotekaTeaserInfo, AutotekaValuationInfo, CatalogResolveResult, + CatalogResolveRequest, + ExternalItemPreviewRequest, + ItemIdRequest, + LeadsRequest, + MonitoringBucketRequest, + MonitoringEventsQuery, MonitoringBucketResult, MonitoringEventsResult, + PlateNumberRequest, + PreviewReportRequest, + RegNumberRequest, + TeaserCreateRequest, + ValuationBySpecificationRequest, + VehicleIdRequest, + VinRequest, ) from avito.core import Transport, ValidationError @@ -48,13 +59,13 @@ class AutotekaVehicle(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def get_catalogs_resolve(self, *, request: AutotekaRequest) -> CatalogResolveResult: + def get_catalogs_resolve(self, *, request: CatalogResolveRequest) -> CatalogResolveResult: return CatalogClient(self.transport).get_catalogs_resolve(request) - def get_leads(self, *, request: AutotekaRequest) -> AutotekaLeadsResult: + def get_leads(self, *, request: LeadsRequest) -> AutotekaLeadsResult: return LeadsClient(self.transport).get_leads(request) - def create_preview_by_vin(self, *, request: AutotekaRequest) -> AutotekaPreviewInfo: + def create_preview_by_vin(self, *, request: VinRequest) -> AutotekaPreviewInfo: return PreviewClient(self.transport).create_by_vin(request) def get_preview(self, *, preview_id: int | str | None = None) -> AutotekaPreviewInfo: @@ -63,23 +74,23 @@ def get_preview(self, *, preview_id: int | str | None = None) -> AutotekaPreview ) def create_preview_by_external_item( - self, *, request: AutotekaRequest + self, *, request: ExternalItemPreviewRequest ) -> AutotekaPreviewInfo: return PreviewClient(self.transport).create_by_external_item(request) - def create_preview_by_item_id(self, *, request: AutotekaRequest) -> AutotekaPreviewInfo: + def create_preview_by_item_id(self, *, request: ItemIdRequest) -> AutotekaPreviewInfo: return PreviewClient(self.transport).create_by_item_id(request) - def create_preview_by_reg_number(self, *, request: AutotekaRequest) -> AutotekaPreviewInfo: + def create_preview_by_reg_number(self, *, request: RegNumberRequest) -> AutotekaPreviewInfo: return PreviewClient(self.transport).create_by_reg_number(request) def create_specification_by_plate_number( - self, *, request: AutotekaRequest + self, *, request: PlateNumberRequest ) -> AutotekaSpecificationInfo: return SpecificationsClient(self.transport).create_by_plate_number(request) def create_specification_by_vehicle_id( - self, *, request: AutotekaRequest + self, *, request: VehicleIdRequest ) -> AutotekaSpecificationInfo: return SpecificationsClient(self.transport).create_by_vehicle_id(request) @@ -92,7 +103,7 @@ def get_specification_by_id( specification_id=specification_id or self._require_resource_id("specification_id") ) - def create_teaser(self, *, request: AutotekaRequest) -> AutotekaTeaserInfo: + def create_teaser(self, *, request: TeaserCreateRequest) -> AutotekaTeaserInfo: return TeaserClient(self.transport).create(request) def get_teaser(self, *, teaser_id: int | str | None = None) -> AutotekaTeaserInfo: @@ -116,10 +127,10 @@ class AutotekaReport(DomainObject): def get_active_package(self) -> AutotekaPackageInfo: return ReportClient(self.transport).get_active_package() - def create_report(self, *, request: AutotekaRequest) -> AutotekaReportInfo: + def create_report(self, *, request: PreviewReportRequest) -> AutotekaReportInfo: return ReportClient(self.transport).create_report(request) - def create_report_by_vehicle_id(self, *, request: AutotekaRequest) -> AutotekaReportInfo: + def create_report_by_vehicle_id(self, *, request: VehicleIdRequest) -> AutotekaReportInfo: return ReportClient(self.transport).create_report_by_vehicle_id(request) def list_report_list(self) -> AutotekaReportsResult: @@ -131,12 +142,12 @@ def get_report(self, *, report_id: int | str | None = None) -> AutotekaReportInf ) def create_sync_report_by_reg_number( - self, *, request: AutotekaRequest + self, *, request: RegNumberRequest ) -> AutotekaReportInfo: return ReportClient(self.transport).create_sync_report_by_reg_number(request) def create_sync_report_by_vin( - self, *, request: AutotekaRequest + self, *, request: VinRequest ) -> AutotekaReportInfo: return ReportClient(self.transport).create_sync_report_by_vin(request) @@ -154,7 +165,7 @@ class AutotekaMonitoring(DomainObject): user_id: int | str | None = None def create_monitoring_bucket_add( - self, *, request: AutotekaRequest + self, *, request: MonitoringBucketRequest ) -> MonitoringBucketResult: return MonitoringClient(self.transport).add_bucket(request) @@ -162,14 +173,14 @@ def list_monitoring_bucket_delete(self) -> MonitoringBucketResult: return MonitoringClient(self.transport).delete_bucket() def delete_monitoring_bucket_remove( - self, *, request: AutotekaRequest + self, *, request: MonitoringBucketRequest ) -> MonitoringBucketResult: return MonitoringClient(self.transport).remove_bucket(request) def get_monitoring_reg_actions( self, *, - query: AutotekaQuery | None = None, + query: MonitoringEventsQuery | None = None, ) -> MonitoringEventsResult: return MonitoringClient(self.transport).get_reg_actions(query=query) @@ -181,7 +192,7 @@ class AutotekaScoring(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def create_scoring_by_vehicle_id(self, *, request: AutotekaRequest) -> AutotekaScoringInfo: + def create_scoring_by_vehicle_id(self, *, request: VehicleIdRequest) -> AutotekaScoringInfo: return ScoringClient(self.transport).create_by_vehicle_id(request) def get_scoring_by_id(self, *, scoring_id: int | str | None = None) -> AutotekaScoringInfo: @@ -203,7 +214,7 @@ class AutotekaValuation(DomainObject): user_id: int | str | None = None def get_valuation_by_specification( - self, *, request: AutotekaRequest + self, *, request: ValuationBySpecificationRequest ) -> AutotekaValuationInfo: return ValuationClient(self.transport).get_by_specification(request) diff --git a/avito/autoteka/models.py b/avito/autoteka/models.py index dee52a4..0d1e1d7 100644 --- a/avito/autoteka/models.py +++ b/avito/autoteka/models.py @@ -9,27 +9,164 @@ @dataclass(slots=True, frozen=True) -class AutotekaRequest: - """Унифицированный typed request для Автотеки.""" +class CatalogResolveRequest: + """Запрос актуализации параметров автокаталога.""" - payload: Mapping[str, object] + brand_id: int def to_payload(self) -> dict[str, object]: - """Сериализует payload запроса.""" + """Сериализует запрос автокаталога.""" - return dict(self.payload) + return {"brandId": self.brand_id} @dataclass(slots=True, frozen=True) -class AutotekaQuery: - """Унифицированный typed query для Автотеки.""" +class LeadsRequest: + """Запрос событий сервиса Сигнал.""" - params: Mapping[str, object] + limit: int + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос событий Сигнал.""" + + return {"limit": self.limit} + + +@dataclass(slots=True, frozen=True) +class VinRequest: + """Запрос по VIN.""" + + vin: str + + def to_payload(self) -> dict[str, object]: + """Сериализует VIN-запрос.""" + + return {"vin": self.vin} + + +@dataclass(slots=True, frozen=True) +class VehicleIdRequest: + """Запрос по идентификатору автомобиля.""" + + vehicle_id: str + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос по vehicle id.""" + + return {"vehicleId": self.vehicle_id} + + +@dataclass(slots=True, frozen=True) +class ItemIdRequest: + """Запрос по идентификатору объявления.""" + + item_id: int + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос по item id.""" + + return {"itemId": self.item_id} + + +@dataclass(slots=True, frozen=True) +class ExternalItemPreviewRequest: + """Запрос превью по внешнему объявлению.""" + + item_id: str + site: str + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос внешнего объявления.""" + + return {"itemId": self.item_id, "site": self.site} + + +@dataclass(slots=True, frozen=True) +class RegNumberRequest: + """Запрос по государственному номеру.""" + + reg_number: str + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос по госномеру.""" + + return {"regNumber": self.reg_number} + + +@dataclass(slots=True, frozen=True) +class PlateNumberRequest: + """Запрос по номерному знаку.""" + + plate_number: str + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос по номерному знаку.""" + + return {"plateNumber": self.plate_number} + + +@dataclass(slots=True, frozen=True) +class PreviewReportRequest: + """Запрос отчета по preview id.""" + + preview_id: int + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос отчета по preview id.""" + + return {"previewId": self.preview_id} + + +@dataclass(slots=True, frozen=True) +class MonitoringBucketRequest: + """Запрос изменения списка мониторинга.""" + + vehicles: list[str] + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос изменения списка мониторинга.""" + + return {"vehicles": list(self.vehicles)} + + +@dataclass(slots=True, frozen=True) +class MonitoringEventsQuery: + """Query событий мониторинга.""" + + limit: int | None = None def to_params(self) -> dict[str, object]: - """Сериализует query-параметры запроса.""" + """Сериализует query событий мониторинга.""" + + params: dict[str, object] = {} + if self.limit is not None: + params["limit"] = self.limit + return params + + +@dataclass(slots=True, frozen=True) +class TeaserCreateRequest: + """Запрос создания тизера.""" + + vehicle_id: str + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос создания тизера.""" + + return {"vehicleId": self.vehicle_id} + + +@dataclass(slots=True, frozen=True) +class ValuationBySpecificationRequest: + """Запрос оценки автомобиля по specification id.""" + + specification_id: int + mileage: int + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос оценки автомобиля.""" - return dict(self.params) + return {"specificationId": self.specification_id, "mileage": self.mileage} @dataclass(slots=True, frozen=True) diff --git a/avito/jobs/__init__.py b/avito/jobs/__init__.py index 62f48cc..fe07c5d 100644 --- a/avito/jobs/__init__.py +++ b/avito/jobs/__init__.py @@ -2,27 +2,46 @@ from avito.jobs.domain import Application, DomainObject, JobDictionary, JobWebhook, Resume, Vacancy from avito.jobs.models import ( + ApplicationActionRequest, ApplicationIdsResult, + ApplicationIdsQuery, + ApplicationIdsRequest, ApplicationsResult, ApplicationStatesResult, + ApplicationViewedItem, + ApplicationViewedRequest, JobActionResult, JobDictionariesResult, JobDictionaryValuesResult, + JobWebhookUpdateRequest, JobWebhookInfo, JobWebhooksResult, ResumeContactInfo, ResumeInfo, + ResumeSearchQuery, ResumesResult, VacanciesResult, VacancyInfo, + VacancyArchiveRequest, + VacancyAutoRenewalRequest, + VacancyCreateRequest, + VacancyIdsRequest, + VacancyProlongateRequest, + VacanciesQuery, VacancyStatusesResult, + VacancyUpdateRequest, ) __all__ = ( "Application", + "ApplicationActionRequest", "ApplicationIdsResult", + "ApplicationIdsQuery", + "ApplicationIdsRequest", "ApplicationsResult", "ApplicationStatesResult", + "ApplicationViewedItem", + "ApplicationViewedRequest", "DomainObject", "JobActionResult", "JobDictionariesResult", @@ -30,13 +49,22 @@ "JobDictionaryValuesResult", "JobWebhook", "JobWebhookInfo", + "JobWebhookUpdateRequest", "JobWebhooksResult", "Resume", "ResumeContactInfo", "ResumeInfo", + "ResumeSearchQuery", "ResumesResult", "VacanciesResult", "Vacancy", + "VacancyArchiveRequest", + "VacancyAutoRenewalRequest", + "VacancyCreateRequest", "VacancyInfo", + "VacancyIdsRequest", + "VacancyProlongateRequest", "VacancyStatusesResult", + "VacanciesQuery", + "VacancyUpdateRequest", ) diff --git a/avito/jobs/client.py b/avito/jobs/client.py index 83187c8..d48babf 100644 --- a/avito/jobs/client.py +++ b/avito/jobs/client.py @@ -23,22 +23,33 @@ map_vacancy_statuses, ) from avito.jobs.models import ( + ApplicationActionRequest, ApplicationIdsResult, + ApplicationIdsQuery, + ApplicationIdsRequest, ApplicationsResult, ApplicationStatesResult, + ApplicationViewedRequest, JobActionResult, JobDictionariesResult, JobDictionaryValuesResult, - JobsQuery, - JobsRequest, + JobWebhookUpdateRequest, JobWebhookInfo, JobWebhooksResult, ResumeContactInfo, ResumeInfo, + ResumeSearchQuery, ResumesResult, VacanciesResult, VacancyInfo, + VacancyArchiveRequest, + VacancyAutoRenewalRequest, + VacancyCreateRequest, + VacancyIdsRequest, + VacancyProlongateRequest, + VacanciesQuery, VacancyStatusesResult, + VacancyUpdateRequest, ) @@ -48,12 +59,12 @@ class ApplicationsClient: transport: Transport - def apply_actions(self, request: JobsRequest) -> JobActionResult: + def apply_actions(self, request: ApplicationActionRequest) -> JobActionResult: return self._post_action( "/job/v1/applications/apply_actions", "jobs.applications.apply_actions", request ) - def get_by_ids(self, request: JobsRequest) -> ApplicationsResult: + def get_by_ids(self, request: ApplicationIdsRequest) -> ApplicationsResult: return request_public_model( self.transport, "POST", @@ -63,7 +74,7 @@ def get_by_ids(self, request: JobsRequest) -> ApplicationsResult: json_body=request.to_payload(), ) - def get_ids(self, *, query: JobsQuery) -> ApplicationIdsResult: + def get_ids(self, *, query: ApplicationIdsQuery) -> ApplicationIdsResult: return request_public_model( self.transport, "GET", @@ -82,12 +93,17 @@ def get_states(self) -> ApplicationStatesResult: mapper=map_application_states, ) - def set_is_viewed(self, request: JobsRequest) -> JobActionResult: + def set_is_viewed(self, request: ApplicationViewedRequest) -> JobActionResult: return self._post_action( "/job/v1/applications/set_is_viewed", "jobs.applications.set_is_viewed", request ) - def _post_action(self, path: str, operation: str, request: JobsRequest) -> JobActionResult: + def _post_action( + self, + path: str, + operation: str, + request: ApplicationActionRequest | ApplicationViewedRequest, + ) -> JobActionResult: return request_public_model( self.transport, "POST", @@ -113,7 +129,7 @@ def get_webhook(self) -> JobWebhookInfo: mapper=map_job_webhook, ) - def put_webhook(self, request: JobsRequest) -> JobWebhookInfo: + def put_webhook(self, request: JobWebhookUpdateRequest) -> JobWebhookInfo: return request_public_model( self.transport, "PUT", @@ -149,7 +165,7 @@ class ResumeClient: transport: Transport - def search(self, *, query: JobsQuery | None = None) -> ResumesResult: + def search(self, *, query: ResumeSearchQuery | None = None) -> ResumesResult: return request_public_model( self.transport, "GET", @@ -184,7 +200,7 @@ class VacanciesClient: transport: Transport - def create_v1(self, request: JobsRequest) -> JobActionResult: + def create_v1(self, request: VacancyCreateRequest) -> JobActionResult: return request_public_model( self.transport, "POST", @@ -194,7 +210,12 @@ def create_v1(self, request: JobsRequest) -> JobActionResult: json_body=request.to_payload(), ) - def archive_v1(self, *, vacancy_id: int | str, request: JobsRequest) -> JobActionResult: + def archive_v1( + self, + *, + vacancy_id: int | str, + request: VacancyArchiveRequest, + ) -> JobActionResult: return request_public_model( self.transport, "PUT", @@ -204,7 +225,12 @@ def archive_v1(self, *, vacancy_id: int | str, request: JobsRequest) -> JobActio json_body=request.to_payload(), ) - def update_v1(self, *, vacancy_id: int | str, request: JobsRequest) -> JobActionResult: + def update_v1( + self, + *, + vacancy_id: int | str, + request: VacancyUpdateRequest, + ) -> JobActionResult: return request_public_model( self.transport, "PUT", @@ -214,7 +240,12 @@ def update_v1(self, *, vacancy_id: int | str, request: JobsRequest) -> JobAction json_body=request.to_payload(), ) - def prolongate_v1(self, *, vacancy_id: int | str, request: JobsRequest) -> JobActionResult: + def prolongate_v1( + self, + *, + vacancy_id: int | str, + request: VacancyProlongateRequest, + ) -> JobActionResult: return request_public_model( self.transport, "POST", @@ -224,7 +255,7 @@ def prolongate_v1(self, *, vacancy_id: int | str, request: JobsRequest) -> JobAc json_body=request.to_payload(), ) - def list_v2(self, *, query: JobsQuery | None = None) -> VacanciesResult: + def list_v2(self, *, query: VacanciesQuery | None = None) -> VacanciesResult: return request_public_model( self.transport, "GET", @@ -234,7 +265,7 @@ def list_v2(self, *, query: JobsQuery | None = None) -> VacanciesResult: params=query.to_params() if query is not None else None, ) - def create_v2(self, request: JobsRequest) -> JobActionResult: + def create_v2(self, request: VacancyCreateRequest) -> JobActionResult: return request_public_model( self.transport, "POST", @@ -244,7 +275,7 @@ def create_v2(self, request: JobsRequest) -> JobActionResult: json_body=request.to_payload(), ) - def get_by_ids_v2(self, request: JobsRequest) -> VacanciesResult: + def get_by_ids_v2(self, request: VacancyIdsRequest) -> VacanciesResult: return request_public_model( self.transport, "POST", @@ -254,7 +285,7 @@ def get_by_ids_v2(self, request: JobsRequest) -> VacanciesResult: json_body=request.to_payload(), ) - def get_statuses_v2(self, request: JobsRequest) -> VacancyStatusesResult: + def get_statuses_v2(self, request: VacancyIdsRequest) -> VacancyStatusesResult: return request_public_model( self.transport, "POST", @@ -264,7 +295,7 @@ def get_statuses_v2(self, request: JobsRequest) -> VacancyStatusesResult: json_body=request.to_payload(), ) - def update_v2(self, *, vacancy_uuid: str, request: JobsRequest) -> JobActionResult: + def update_v2(self, *, vacancy_uuid: str, request: VacancyUpdateRequest) -> JobActionResult: return request_public_model( self.transport, "POST", @@ -275,7 +306,7 @@ def update_v2(self, *, vacancy_uuid: str, request: JobsRequest) -> JobActionResu ) def get_item_v2( - self, *, vacancy_id: int | str, query: JobsQuery | None = None + self, *, vacancy_id: int | str, query: VacanciesQuery | None = None ) -> VacancyInfo: return request_public_model( self.transport, @@ -286,7 +317,12 @@ def get_item_v2( params=query.to_params() if query is not None else None, ) - def auto_renewal_v2(self, *, vacancy_uuid: str, request: JobsRequest) -> JobActionResult: + def auto_renewal_v2( + self, + *, + vacancy_uuid: str, + request: VacancyAutoRenewalRequest, + ) -> JobActionResult: return request_public_model( self.transport, "PUT", diff --git a/avito/jobs/domain.py b/avito/jobs/domain.py index ecf9e44..5d347a2 100644 --- a/avito/jobs/domain.py +++ b/avito/jobs/domain.py @@ -13,22 +13,33 @@ WebhookClient, ) from avito.jobs.models import ( + ApplicationActionRequest, ApplicationIdsResult, + ApplicationIdsQuery, + ApplicationIdsRequest, ApplicationsResult, ApplicationStatesResult, + ApplicationViewedRequest, JobActionResult, JobDictionariesResult, JobDictionaryValuesResult, - JobsQuery, - JobsRequest, + JobWebhookUpdateRequest, JobWebhookInfo, JobWebhooksResult, ResumeContactInfo, ResumeInfo, + ResumeSearchQuery, ResumesResult, VacanciesResult, VacancyInfo, + VacancyArchiveRequest, + VacancyAutoRenewalRequest, + VacancyCreateRequest, + VacancyIdsRequest, + VacancyProlongateRequest, + VacanciesQuery, VacancyStatusesResult, + VacancyUpdateRequest, ) @@ -46,7 +57,7 @@ class Vacancy(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def create(self, *, request: JobsRequest, version: int = 2) -> JobActionResult: + def create(self, *, request: VacancyCreateRequest, version: int = 2) -> JobActionResult: client = VacanciesClient(self.transport) if version == 1: return client.create_v1(request) @@ -55,7 +66,7 @@ def create(self, *, request: JobsRequest, version: int = 2) -> JobActionResult: def update( self, *, - request: JobsRequest, + request: VacancyUpdateRequest, vacancy_id: int | str | None = None, vacancy_uuid: str | None = None, version: int = 2, @@ -70,7 +81,7 @@ def update( ) def delete( - self, *, request: JobsRequest, vacancy_id: int | str | None = None + self, *, request: VacancyArchiveRequest, vacancy_id: int | str | None = None ) -> JobActionResult: return VacanciesClient(self.transport).archive_v1( vacancy_id=vacancy_id or self._require_resource_id(), @@ -78,32 +89,32 @@ def delete( ) def prolongate( - self, *, request: JobsRequest, vacancy_id: int | str | None = None + self, *, request: VacancyProlongateRequest, vacancy_id: int | str | None = None ) -> JobActionResult: return VacanciesClient(self.transport).prolongate_v1( vacancy_id=vacancy_id or self._require_resource_id(), request=request, ) - def list(self, *, query: JobsQuery | None = None) -> VacanciesResult: + def list(self, *, query: VacanciesQuery | None = None) -> VacanciesResult: return VacanciesClient(self.transport).list_v2(query=query) def get( - self, *, vacancy_id: int | str | None = None, query: JobsQuery | None = None + self, *, vacancy_id: int | str | None = None, query: VacanciesQuery | None = None ) -> VacancyInfo: return VacanciesClient(self.transport).get_item_v2( vacancy_id=vacancy_id or self._require_resource_id(), query=query, ) - def get_by_ids(self, *, request: JobsRequest) -> VacanciesResult: + def get_by_ids(self, *, request: VacancyIdsRequest) -> VacanciesResult: return VacanciesClient(self.transport).get_by_ids_v2(request) - def get_statuses(self, *, request: JobsRequest) -> VacancyStatusesResult: + def get_statuses(self, *, request: VacancyIdsRequest) -> VacancyStatusesResult: return VacanciesClient(self.transport).get_statuses_v2(request) def update_auto_renewal( - self, *, request: JobsRequest, vacancy_uuid: str | None = None + self, *, request: VacancyAutoRenewalRequest, vacancy_uuid: str | None = None ) -> JobActionResult: return VacanciesClient(self.transport).auto_renewal_v2( vacancy_uuid=vacancy_uuid or self._require_resource_id(), @@ -123,24 +134,26 @@ class Application(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def apply(self, *, request: JobsRequest) -> JobActionResult: + def apply(self, *, request: ApplicationActionRequest) -> JobActionResult: return ApplicationsClient(self.transport).apply_actions(request) def list( self, *, - request: JobsRequest | None = None, - query: JobsQuery | None = None, + request: ApplicationIdsRequest | None = None, + query: ApplicationIdsQuery | None = None, ) -> ApplicationsResult | ApplicationIdsResult: client = ApplicationsClient(self.transport) if request is not None: return client.get_by_ids(request) - return client.get_ids(query=query or JobsQuery(params={})) + if query is None: + raise ValidationError("Для операции требуется `query` или `request`.") + return client.get_ids(query=query) def get_states(self) -> ApplicationStatesResult: return ApplicationsClient(self.transport).get_states() - def update(self, *, request: JobsRequest) -> JobActionResult: + def update(self, *, request: ApplicationViewedRequest) -> JobActionResult: return ApplicationsClient(self.transport).set_is_viewed(request) @@ -151,7 +164,7 @@ class Resume(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def list(self, *, query: JobsQuery | None = None) -> ResumesResult: + def list(self, *, query: ResumeSearchQuery | None = None) -> ResumesResult: return ResumeClient(self.transport).search(query=query) def get(self, *, resume_id: int | str | None = None) -> ResumeInfo: @@ -183,7 +196,7 @@ def get(self) -> JobWebhookInfo: def list(self) -> JobWebhooksResult: return WebhookClient(self.transport).list_webhooks() - def update(self, *, request: JobsRequest) -> JobWebhookInfo: + def update(self, *, request: JobWebhookUpdateRequest) -> JobWebhookInfo: return WebhookClient(self.transport).put_webhook(request) def delete(self, *, url: str | None = None) -> JobActionResult: diff --git a/avito/jobs/models.py b/avito/jobs/models.py index e99a123..b85b0e6 100644 --- a/avito/jobs/models.py +++ b/avito/jobs/models.py @@ -9,27 +9,176 @@ @dataclass(slots=True, frozen=True) -class JobsRequest: - """Унифицированный typed request для Jobs API.""" +class ApplicationIdsQuery: + """Query списка идентификаторов откликов.""" - payload: Mapping[str, object] + updated_at_from: str + + def to_params(self) -> dict[str, str]: + """Сериализует query-параметры идентификаторов откликов.""" + + return {"updatedAtFrom": self.updated_at_from} + + +@dataclass(slots=True, frozen=True) +class ApplicationIdsRequest: + """Запрос получения откликов по идентификаторам.""" + + ids: list[str] + + def to_payload(self) -> dict[str, object]: + """Сериализует идентификаторы откликов.""" + + return {"ids": list(self.ids)} + + +@dataclass(slots=True, frozen=True) +class ApplicationActionRequest: + """Запрос действия над откликами.""" + + ids: list[str] + action: str + + def to_payload(self) -> dict[str, object]: + """Сериализует действие над откликами.""" + + return {"ids": list(self.ids), "action": self.action} + + +@dataclass(slots=True, frozen=True) +class ApplicationViewedItem: + """Флаг просмотра для отклика.""" + + id: str + is_viewed: bool + + def to_payload(self) -> dict[str, object]: + """Сериализует флаг просмотра отклика.""" + + return {"id": self.id, "is_viewed": self.is_viewed} + + +@dataclass(slots=True, frozen=True) +class ApplicationViewedRequest: + """Запрос обновления флага просмотра откликов.""" + + applies: list[ApplicationViewedItem] + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос обновления просмотра откликов.""" + + return {"applies": [item.to_payload() for item in self.applies]} + + +@dataclass(slots=True, frozen=True) +class JobWebhookUpdateRequest: + """Запрос обновления webhook откликов.""" + + url: str + + def to_payload(self) -> dict[str, object]: + """Сериализует webhook откликов.""" + + return {"url": self.url} + + +@dataclass(slots=True, frozen=True) +class ResumeSearchQuery: + """Query поиска резюме.""" + + query: str + + def to_params(self) -> dict[str, str]: + """Сериализует query поиска резюме.""" + + return {"query": self.query} + + +@dataclass(slots=True, frozen=True) +class VacanciesQuery: + """Query списка или карточки вакансий.""" + + query: str | None = None + + def to_params(self) -> dict[str, str]: + """Сериализует query вакансий.""" + + params: dict[str, str] = {} + if self.query is not None: + params["query"] = self.query + return params + + +@dataclass(slots=True, frozen=True) +class VacancyCreateRequest: + """Запрос создания вакансии.""" + + title: str + + def to_payload(self) -> dict[str, object]: + """Сериализует создание вакансии.""" + + return {"title": self.title} + + +@dataclass(slots=True, frozen=True) +class VacancyUpdateRequest: + """Запрос обновления вакансии.""" + + title: str def to_payload(self) -> dict[str, object]: - """Сериализует JSON payload запроса.""" + """Сериализует обновление вакансии.""" - return dict(self.payload) + return {"title": self.title} @dataclass(slots=True, frozen=True) -class JobsQuery: - """Унифицированный typed query для Jobs API.""" +class VacancyArchiveRequest: + """Запрос архивации вакансии v1.""" - params: Mapping[str, object] + employee_id: int + + def to_payload(self) -> dict[str, object]: + """Сериализует архивацию вакансии.""" - def to_params(self) -> dict[str, object]: - """Сериализует query-параметры запроса.""" + return {"employee_id": self.employee_id} + + +@dataclass(slots=True, frozen=True) +class VacancyProlongateRequest: + """Запрос продления вакансии v1.""" + + billing_type: str + + def to_payload(self) -> dict[str, object]: + """Сериализует продление вакансии.""" + + return {"billing_type": self.billing_type} + + +@dataclass(slots=True, frozen=True) +class VacancyIdsRequest: + """Запрос списка вакансий по идентификаторам.""" + + ids: list[int] + + def to_payload(self) -> dict[str, object]: + """Сериализует идентификаторы вакансий.""" + + return {"ids": list(self.ids)} + + +@dataclass(slots=True, frozen=True) +class VacancyAutoRenewalRequest: + """Запрос обновления автообновления вакансии.""" + + auto_renewal: bool + + def to_payload(self) -> dict[str, object]: + """Сериализует флаг автообновления.""" - return dict(self.params) + return {"auto_renewal": self.auto_renewal} @dataclass(slots=True, frozen=True) diff --git a/avito/messenger/__init__.py b/avito/messenger/__init__.py index 5db1875..6b22864 100644 --- a/avito/messenger/__init__.py +++ b/avito/messenger/__init__.py @@ -19,6 +19,8 @@ SpecialOfferStatsResult, SubscriptionsResult, TariffInfo, + UploadImageFile, + UploadImagesRequest, UploadImagesResult, VoiceFilesResult, WebhookActionResult, @@ -41,6 +43,8 @@ "SpecialOfferStatsResult", "SubscriptionsResult", "TariffInfo", + "UploadImageFile", + "UploadImagesRequest", "UploadImagesResult", "VoiceFilesResult", "WebhookActionResult", diff --git a/avito/messenger/client.py b/avito/messenger/client.py index 0aed527..bf6c8ed 100644 --- a/avito/messenger/client.py +++ b/avito/messenger/client.py @@ -38,6 +38,7 @@ TariffInfo, UnsubscribeWebhookRequest, UpdateWebhookRequest, + UploadImagesRequest, UploadImagesResult, VoiceFilesResult, WebhookActionResult, @@ -197,7 +198,7 @@ def upload_images( self, *, user_id: int, - files: dict[str, object], + request: UploadImagesRequest, ) -> UploadImagesResult: """Загружает изображения для сообщений.""" @@ -205,7 +206,7 @@ def upload_images( "POST", f"/messenger/v1/accounts/{user_id}/uploadImages", context=RequestContext("messenger.media.upload_images", allow_retry=True), - files=files, + files=request.to_files(), ) return map_upload_images(payload) diff --git a/avito/messenger/domain.py b/avito/messenger/domain.py index 6c577b4..589981e 100644 --- a/avito/messenger/domain.py +++ b/avito/messenger/domain.py @@ -25,6 +25,8 @@ TariffInfo, UnsubscribeWebhookRequest, UpdateWebhookRequest, + UploadImageFile, + UploadImagesRequest, UploadImagesResult, VoiceFilesResult, WebhookActionResult, @@ -183,11 +185,12 @@ def get_voice_files(self) -> VoiceFilesResult: return MediaClient(self.transport).get_voice_files(user_id=self._require_user_id()) - def upload_images(self, *, files: dict[str, object]) -> UploadImagesResult: + def upload_images(self, *, files: list[UploadImageFile]) -> UploadImagesResult: """Загружает изображения для сообщений.""" return MediaClient(self.transport).upload_images( - user_id=self._require_user_id(), files=files + user_id=self._require_user_id(), + request=UploadImagesRequest(files=files), ) def _require_user_id(self) -> int: diff --git a/avito/messenger/models.py b/avito/messenger/models.py index c3291af..8bb9605 100644 --- a/avito/messenger/models.py +++ b/avito/messenger/models.py @@ -4,6 +4,7 @@ from collections.abc import Mapping from dataclasses import dataclass, field +from typing import BinaryIO from avito.core.serialization import enable_module_serialization @@ -132,6 +133,31 @@ class UploadImagesResult: _payload: Mapping[str, object] = field(default_factory=dict) +@dataclass(slots=True, frozen=True) +class UploadImageFile: + """Файл изображения для загрузки в мессенджер.""" + + field_name: str + filename: str + content: bytes | BinaryIO + content_type: str + + +@dataclass(slots=True, frozen=True) +class UploadImagesRequest: + """Запрос загрузки изображений для сообщений.""" + + files: list[UploadImageFile] + + def to_files(self) -> dict[str, object]: + """Сериализует multipart-структуру для transport.""" + + return { + file.field_name: (file.filename, file.content, file.content_type) + for file in self.files + } + + @dataclass(slots=True, frozen=True) class SubscriptionInfo: """Подписка webhook мессенджера.""" @@ -328,6 +354,8 @@ class TariffInfo: "TariffInfo", "UnsubscribeWebhookRequest", "UpdateWebhookRequest", + "UploadImageFile", + "UploadImagesRequest", "UploadImageResult", "UploadImagesResult", "VoiceFile", diff --git a/avito/orders/__init__.py b/avito/orders/__init__.py index ce6e532..23225b6 100644 --- a/avito/orders/__init__.py +++ b/avito/orders/__init__.py @@ -11,21 +11,42 @@ ) from avito.orders.models import ( CourierRangesResult, + DeliveryAnnouncementRequest, DeliveryEntityResult, + DeliveryParcelIdsRequest, + DeliveryParcelRequest, + DeliveryParcelResultRequest, DeliverySortingCentersResult, DeliveryTaskInfo, LabelPdfResult, LabelTaskResult, + OrderAcceptReturnRequest, OrderActionResult, + OrderApplyTransitionRequest, + OrderCncDetailsRequest, + OrderConfirmationCodeRequest, + OrderCourierRangeRequest, + OrderLabelsRequest, + OrderMarkingsRequest, + OrderTrackingNumberRequest, OrdersResult, + SandboxArea, + SandboxAreasRequest, + StockInfoRequest, StockInfoResult, + StockUpdateEntry, + StockUpdateRequest, StockUpdateResult, ) __all__ = ( "CourierRangesResult", + "DeliveryAnnouncementRequest", "DeliveryEntityResult", "DeliveryOrder", + "DeliveryParcelIdsRequest", + "DeliveryParcelRequest", + "DeliveryParcelResultRequest", "DeliverySortingCentersResult", "DeliveryTask", "DeliveryTaskInfo", @@ -33,11 +54,24 @@ "LabelPdfResult", "LabelTaskResult", "Order", + "OrderAcceptReturnRequest", "OrderActionResult", + "OrderApplyTransitionRequest", + "OrderCncDetailsRequest", + "OrderConfirmationCodeRequest", + "OrderCourierRangeRequest", + "OrderLabelsRequest", "OrderLabel", + "OrderMarkingsRequest", + "OrderTrackingNumberRequest", "OrdersResult", + "SandboxArea", + "SandboxAreasRequest", "SandboxDelivery", "Stock", + "StockInfoRequest", "StockInfoResult", + "StockUpdateEntry", + "StockUpdateRequest", "StockUpdateResult", ) diff --git a/avito/orders/client.py b/avito/orders/client.py index 9dc23d5..2437fd4 100644 --- a/avito/orders/client.py +++ b/avito/orders/client.py @@ -18,15 +18,30 @@ ) from avito.orders.models import ( CourierRangesResult, + DeliveryAnnouncementRequest, DeliveryEntityResult, + DeliveryParcelIdsRequest, + DeliveryParcelRequest, + DeliveryParcelResultRequest, DeliverySortingCentersResult, DeliveryTaskInfo, LabelPdfResult, LabelTaskResult, + OrderAcceptReturnRequest, OrderActionResult, + OrderApplyTransitionRequest, + OrderCncDetailsRequest, + OrderConfirmationCodeRequest, + OrderCourierRangeRequest, + OrderLabelsRequest, + OrderMarkingsRequest, + OrderTrackingNumberRequest, OrdersRequest, OrdersResult, + SandboxAreasRequest, + StockInfoRequest, StockInfoResult, + StockUpdateRequest, StockUpdateResult, ) @@ -45,31 +60,31 @@ def list_orders(self) -> OrdersResult: ) return map_orders(payload) - def update_markings(self, request: OrdersRequest) -> OrderActionResult: + def update_markings(self, request: OrderMarkingsRequest) -> OrderActionResult: return self._post_action("/order-management/1/markings", "orders.update_markings", request) - def accept_return_order(self, request: OrdersRequest) -> OrderActionResult: + def accept_return_order(self, request: OrderAcceptReturnRequest) -> OrderActionResult: return self._post_action( "/order-management/1/order/acceptReturnOrder", "orders.accept_return_order", request, ) - def apply_transition(self, request: OrdersRequest) -> OrderActionResult: + def apply_transition(self, request: OrderApplyTransitionRequest) -> OrderActionResult: return self._post_action( "/order-management/1/order/applyTransition", "orders.apply_transition", request, ) - def check_confirmation_code(self, request: OrdersRequest) -> OrderActionResult: + def check_confirmation_code(self, request: OrderConfirmationCodeRequest) -> OrderActionResult: return self._post_action( "/order-management/1/order/checkConfirmationCode", "orders.check_confirmation_code", request, ) - def set_cnc_details(self, request: OrdersRequest) -> OrderActionResult: + def set_cnc_details(self, request: OrderCncDetailsRequest) -> OrderActionResult: return self._post_action( "/order-management/1/order/cncSetDetails", "orders.set_cnc_details", @@ -84,21 +99,32 @@ def get_courier_delivery_range(self) -> CourierRangesResult: ) return map_courier_ranges(payload) - def set_courier_delivery_range(self, request: OrdersRequest) -> OrderActionResult: + def set_courier_delivery_range(self, request: OrderCourierRangeRequest) -> OrderActionResult: return self._post_action( "/order-management/1/order/setCourierDeliveryRange", "orders.set_courier_delivery_range", request, ) - def set_tracking_number(self, request: OrdersRequest) -> OrderActionResult: + def set_tracking_number(self, request: OrderTrackingNumberRequest) -> OrderActionResult: return self._post_action( "/order-management/1/order/setTrackingNumber", "orders.set_tracking_number", request, ) - def _post_action(self, path: str, operation: str, request: OrdersRequest) -> OrderActionResult: + def _post_action( + self, + path: str, + operation: str, + request: OrderMarkingsRequest + | OrderAcceptReturnRequest + | OrderApplyTransitionRequest + | OrderConfirmationCodeRequest + | OrderCncDetailsRequest + | OrderCourierRangeRequest + | OrderTrackingNumberRequest, + ) -> OrderActionResult: payload = self.transport.request_json( "POST", path, @@ -114,10 +140,10 @@ class LabelsClient: transport: Transport - def create_generate_labels(self, request: OrdersRequest) -> LabelTaskResult: + def create_generate_labels(self, request: OrderLabelsRequest) -> LabelTaskResult: return self._create("/order-management/1/orders/labels", "orders.labels.create", request) - def create_generate_labels_extended(self, request: OrdersRequest) -> LabelTaskResult: + def create_generate_labels_extended(self, request: OrderLabelsRequest) -> LabelTaskResult: return self._create( "/order-management/1/orders/labels/extended", "orders.labels.create_extended", @@ -131,7 +157,7 @@ def get_download_label(self, *, task_id: str) -> LabelPdfResult: ) return LabelPdfResult(binary=binary) - def _create(self, path: str, operation: str, request: OrdersRequest) -> LabelTaskResult: + def _create(self, path: str, operation: str, request: OrderLabelsRequest) -> LabelTaskResult: payload = self.transport.request_json( "POST", path, @@ -147,28 +173,36 @@ class DeliveryClient: transport: Transport - def create_announcement(self, request: OrdersRequest) -> DeliveryEntityResult: + def create_announcement(self, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: return self._post("/createAnnouncement", "orders.delivery.create_announcement", request) - def cancel_announcement(self, request: OrdersRequest) -> DeliveryEntityResult: + def cancel_announcement(self, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: return self._post("/cancelAnnouncement", "orders.delivery.cancel_announcement", request) - def create_parcel(self, request: OrdersRequest) -> DeliveryEntityResult: + def create_parcel(self, request: DeliveryParcelRequest) -> DeliveryEntityResult: return self._post("/createParcel", "orders.delivery.create_parcel", request) - def change_parcel_result(self, request: OrdersRequest) -> DeliveryEntityResult: + def change_parcel_result(self, request: DeliveryParcelResultRequest) -> DeliveryEntityResult: return self._post( "/delivery/order/changeParcelResult", "orders.delivery.change_parcel_result", request, ) - def update_change_parcels(self, request: OrdersRequest) -> DeliveryEntityResult: + def update_change_parcels(self, request: DeliveryParcelIdsRequest) -> DeliveryEntityResult: return self._post( "/sandbox/changeParcels", "orders.delivery.update_change_parcels", request ) - def _post(self, path: str, operation: str, request: OrdersRequest) -> DeliveryEntityResult: + def _post( + self, + path: str, + operation: str, + request: DeliveryAnnouncementRequest + | DeliveryParcelRequest + | DeliveryParcelResultRequest + | DeliveryParcelIdsRequest, + ) -> DeliveryEntityResult: payload = self.transport.request_json( "POST", path, @@ -184,12 +218,12 @@ class SandboxDeliveryClient: transport: Transport - def create_announcement(self, request: OrdersRequest) -> DeliveryEntityResult: + def create_announcement(self, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/announcements/create", "orders.sandbox.create_announcement", request ) - def track_announcement(self, request: OrdersRequest) -> DeliveryEntityResult: + def track_announcement(self, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/announcements/track", "orders.sandbox.track_announcement", request ) @@ -250,7 +284,7 @@ def add_sorting_center(self, request: OrdersRequest) -> DeliveryEntityResult: request, ) - def add_areas(self, *, tariff_id: str, request: OrdersRequest) -> DeliveryEntityResult: + def add_areas(self, *, tariff_id: str, request: SandboxAreasRequest) -> DeliveryEntityResult: return self._post( f"/delivery-sandbox/tariffs/{tariff_id}/areas", "orders.sandbox.add_areas", @@ -335,12 +369,20 @@ def v1_get_registered_parcel_id(self, request: OrdersRequest) -> DeliveryEntityR request, ) - def create_parcel_v2(self, request: OrdersRequest) -> DeliveryEntityResult: + def create_parcel_v2(self, request: DeliveryParcelRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v2/createParcel", "orders.sandbox.create_parcel_v2", request ) - def _post(self, path: str, operation: str, request: OrdersRequest) -> DeliveryEntityResult: + def _post( + self, + path: str, + operation: str, + request: OrdersRequest + | DeliveryAnnouncementRequest + | SandboxAreasRequest + | DeliveryParcelRequest, + ) -> DeliveryEntityResult: payload = self.transport.request_json( "POST", path, @@ -371,7 +413,7 @@ class StockManagementClient: transport: Transport - def get_info(self, request: OrdersRequest) -> StockInfoResult: + def get_info(self, request: StockInfoRequest) -> StockInfoResult: payload = self.transport.request_json( "POST", "/stock-management/1/info", @@ -380,7 +422,7 @@ def get_info(self, request: OrdersRequest) -> StockInfoResult: ) return map_stock_info(payload) - def update_stocks(self, request: OrdersRequest) -> StockUpdateResult: + def update_stocks(self, request: StockUpdateRequest) -> StockUpdateResult: payload = self.transport.request_json( "PUT", "/stock-management/1/stocks", diff --git a/avito/orders/domain.py b/avito/orders/domain.py index 364696f..ef8a099 100644 --- a/avito/orders/domain.py +++ b/avito/orders/domain.py @@ -15,15 +15,30 @@ ) from avito.orders.models import ( CourierRangesResult, + DeliveryAnnouncementRequest, DeliveryEntityResult, + DeliveryParcelIdsRequest, + DeliveryParcelRequest, + DeliveryParcelResultRequest, DeliverySortingCentersResult, DeliveryTaskInfo, LabelPdfResult, LabelTaskResult, + OrderAcceptReturnRequest, OrderActionResult, + OrderApplyTransitionRequest, + OrderCncDetailsRequest, + OrderConfirmationCodeRequest, + OrderCourierRangeRequest, + OrderLabelsRequest, + OrderMarkingsRequest, + OrderTrackingNumberRequest, OrdersRequest, OrdersResult, + SandboxAreasRequest, + StockInfoRequest, StockInfoResult, + StockUpdateRequest, StockUpdateResult, ) @@ -45,28 +60,28 @@ class Order(DomainObject): def list(self) -> OrdersResult: return OrdersClient(self.transport).list_orders() - def update_markings(self, *, request: OrdersRequest) -> OrderActionResult: + def update_markings(self, *, request: OrderMarkingsRequest) -> OrderActionResult: return OrdersClient(self.transport).update_markings(request) - def accept_return_order(self, *, request: OrdersRequest) -> OrderActionResult: + def accept_return_order(self, *, request: OrderAcceptReturnRequest) -> OrderActionResult: return OrdersClient(self.transport).accept_return_order(request) - def apply(self, *, request: OrdersRequest) -> OrderActionResult: + def apply(self, *, request: OrderApplyTransitionRequest) -> OrderActionResult: return OrdersClient(self.transport).apply_transition(request) - def check_confirmation_code(self, *, request: OrdersRequest) -> OrderActionResult: + def check_confirmation_code(self, *, request: OrderConfirmationCodeRequest) -> OrderActionResult: return OrdersClient(self.transport).check_confirmation_code(request) - def set_cnc_details(self, *, request: OrdersRequest) -> OrderActionResult: + def set_cnc_details(self, *, request: OrderCncDetailsRequest) -> OrderActionResult: return OrdersClient(self.transport).set_cnc_details(request) def get_courier_delivery_range(self) -> CourierRangesResult: return OrdersClient(self.transport).get_courier_delivery_range() - def set_courier_delivery_range(self, *, request: OrdersRequest) -> OrderActionResult: + def set_courier_delivery_range(self, *, request: OrderCourierRangeRequest) -> OrderActionResult: return OrdersClient(self.transport).set_courier_delivery_range(request) - def update_tracking_number(self, *, request: OrdersRequest) -> OrderActionResult: + def update_tracking_number(self, *, request: OrderTrackingNumberRequest) -> OrderActionResult: return OrdersClient(self.transport).set_tracking_number(request) @@ -77,7 +92,7 @@ class OrderLabel(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def create(self, *, request: OrdersRequest, extended: bool = False) -> LabelTaskResult: + def create(self, *, request: OrderLabelsRequest, extended: bool = False) -> LabelTaskResult: client = LabelsClient(self.transport) if extended: return client.create_generate_labels_extended(request) @@ -100,19 +115,19 @@ class DeliveryOrder(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def create_announcement(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def create_announcement(self, *, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: return DeliveryClient(self.transport).create_announcement(request) - def delete(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def delete(self, *, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: return DeliveryClient(self.transport).cancel_announcement(request) - def create(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def create(self, *, request: DeliveryParcelRequest) -> DeliveryEntityResult: return DeliveryClient(self.transport).create_parcel(request) - def update_change_parcels(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def update_change_parcels(self, *, request: DeliveryParcelIdsRequest) -> DeliveryEntityResult: return DeliveryClient(self.transport).update_change_parcels(request) - def create_change_parcel_result(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def create_change_parcel_result(self, *, request: DeliveryParcelResultRequest) -> DeliveryEntityResult: return DeliveryClient(self.transport).change_parcel_result(request) @@ -123,10 +138,10 @@ class SandboxDelivery(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def create_announcement(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def create_announcement(self, *, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).create_announcement(request) - def track_announcement(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def track_announcement(self, *, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).track_announcement(request) def update_custom_area_schedule(self, *, request: OrdersRequest) -> DeliveryEntityResult: @@ -156,7 +171,7 @@ def list_sorting_center(self) -> DeliverySortingCentersResult: def add_sorting_center(self, *, request: OrdersRequest) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).add_sorting_center(request) - def add_areas(self, *, tariff_id: str, request: OrdersRequest) -> DeliveryEntityResult: + def add_areas(self, *, tariff_id: str, request: SandboxAreasRequest) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).add_areas( tariff_id=tariff_id, request=request ) @@ -186,7 +201,7 @@ def update_terms( def add_tariff_v2(self, *, request: OrdersRequest) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).add_tariff_v2(request) - def create_parcel(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def create_parcel(self, *, request: DeliveryParcelRequest) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).create_parcel_v2(request) def cancel_announcement_v1(self, *, request: OrdersRequest) -> DeliveryEntityResult: @@ -244,10 +259,10 @@ class Stock(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def get(self, *, request: OrdersRequest) -> StockInfoResult: + def get(self, *, request: StockInfoRequest) -> StockInfoResult: return StockManagementClient(self.transport).get_info(request) - def update(self, *, request: OrdersRequest) -> StockUpdateResult: + def update(self, *, request: StockUpdateRequest) -> StockUpdateResult: return StockManagementClient(self.transport).update_stocks(request) diff --git a/avito/orders/models.py b/avito/orders/models.py index b86f51b..a086412 100644 --- a/avito/orders/models.py +++ b/avito/orders/models.py @@ -13,16 +13,194 @@ @dataclass(slots=True, frozen=True) class OrdersRequest: - """Унифицированный typed request для Orders API.""" + """Временный generic request для ещё не мигрированных endpoints orders.""" payload: Mapping[str, object] def to_payload(self) -> dict[str, object]: - """Сериализует payload запроса.""" - return dict(self.payload) +@dataclass(slots=True, frozen=True) +class OrderMarkingsRequest: + """Запрос обновления маркировок заказа.""" + + order_id: str + codes: list[str] + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id, "codes": list(self.codes)} + + +@dataclass(slots=True, frozen=True) +class OrderAcceptReturnRequest: + """Запрос подтверждения возврата заказа.""" + + order_id: str + postal_office_id: str + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id, "postalOfficeId": self.postal_office_id} + + +@dataclass(slots=True, frozen=True) +class OrderApplyTransitionRequest: + """Запрос перехода заказа в другой статус.""" + + order_id: str + transition: str + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id, "transition": self.transition} + + +@dataclass(slots=True, frozen=True) +class OrderConfirmationCodeRequest: + """Запрос проверки кода подтверждения заказа.""" + + order_id: str + code: str + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id, "code": self.code} + + +@dataclass(slots=True, frozen=True) +class OrderCncDetailsRequest: + """Запрос установки деталей cnc-заказа.""" + + order_id: str + pickup_point_id: str + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id, "pickupPointId": self.pickup_point_id} + + +@dataclass(slots=True, frozen=True) +class OrderCourierRangeRequest: + """Запрос установки интервала курьерской доставки.""" + + order_id: str + interval_id: str + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id, "intervalId": self.interval_id} + + +@dataclass(slots=True, frozen=True) +class OrderTrackingNumberRequest: + """Запрос установки трек-номера.""" + + order_id: str + tracking_number: str + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id, "trackingNumber": self.tracking_number} + + +@dataclass(slots=True, frozen=True) +class OrderLabelsRequest: + """Запрос генерации этикеток.""" + + order_ids: list[str] + + def to_payload(self) -> dict[str, object]: + return {"orderIds": list(self.order_ids)} + + +@dataclass(slots=True, frozen=True) +class DeliveryAnnouncementRequest: + """Запрос создания или отмены анонса доставки.""" + + order_id: str + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id} + + +@dataclass(slots=True, frozen=True) +class DeliveryParcelRequest: + """Запрос создания посылки.""" + + order_id: str + parcel_id: str + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id, "parcelId": self.parcel_id} + + +@dataclass(slots=True, frozen=True) +class DeliveryParcelResultRequest: + """Запрос передачи результата по посылке.""" + + parcel_id: str + result: str + + def to_payload(self) -> dict[str, object]: + return {"parcelId": self.parcel_id, "result": self.result} + + +@dataclass(slots=True, frozen=True) +class DeliveryParcelIdsRequest: + """Запрос пакетной операции по посылкам.""" + + parcel_ids: list[str] + + def to_payload(self) -> dict[str, object]: + return {"parcelIds": list(self.parcel_ids)} + + +@dataclass(slots=True, frozen=True) +class SandboxArea: + """Зона sandbox-доставки.""" + + city: str + + def to_payload(self) -> dict[str, object]: + return {"city": self.city} + + +@dataclass(slots=True, frozen=True) +class SandboxAreasRequest: + """Запрос добавления зон sandbox-доставки.""" + + areas: list[SandboxArea] + + def to_payload(self) -> dict[str, object]: + return {"areas": [area.to_payload() for area in self.areas]} + + +@dataclass(slots=True, frozen=True) +class StockInfoRequest: + """Запрос текущих остатков.""" + + item_ids: list[int] + + def to_payload(self) -> dict[str, object]: + return {"itemIds": list(self.item_ids)} + + +@dataclass(slots=True, frozen=True) +class StockUpdateEntry: + """Остаток по одному объявлению.""" + + item_id: int + quantity: int + + def to_payload(self) -> dict[str, object]: + return {"item_id": self.item_id, "quantity": self.quantity} + + +@dataclass(slots=True, frozen=True) +class StockUpdateRequest: + """Запрос обновления остатков.""" + + stocks: list[StockUpdateEntry] + + def to_payload(self) -> dict[str, object]: + return {"stocks": [stock.to_payload() for stock in self.stocks]} + + @dataclass(slots=True, frozen=True) class OrderSummary: """Краткая информация о заказе.""" diff --git a/avito/realty/__init__.py b/avito/realty/__init__.py index 3e97dbd..652d149 100644 --- a/avito/realty/__init__.py +++ b/avito/realty/__init__.py @@ -10,9 +10,16 @@ from avito.realty.models import ( RealtyActionResult, RealtyAnalyticsInfo, + RealtyBaseParamsUpdateRequest, RealtyBookingInfo, + RealtyBookingsQuery, RealtyBookingsResult, + RealtyBookingsUpdateRequest, + RealtyInterval, + RealtyIntervalsRequest, RealtyMarketPriceInfo, + RealtyPricePeriod, + RealtyPricesUpdateRequest, ) __all__ = ( @@ -20,10 +27,17 @@ "RealtyActionResult", "RealtyAnalyticsInfo", "RealtyAnalyticsReport", + "RealtyBaseParamsUpdateRequest", "RealtyBooking", "RealtyBookingInfo", + "RealtyBookingsQuery", "RealtyBookingsResult", + "RealtyBookingsUpdateRequest", + "RealtyInterval", + "RealtyIntervalsRequest", "RealtyListing", "RealtyMarketPriceInfo", + "RealtyPricePeriod", "RealtyPricing", + "RealtyPricesUpdateRequest", ) diff --git a/avito/realty/client.py b/avito/realty/client.py index ac93267..6a6304a 100644 --- a/avito/realty/client.py +++ b/avito/realty/client.py @@ -9,10 +9,13 @@ from avito.realty.models import ( RealtyActionResult, RealtyAnalyticsInfo, + RealtyBaseParamsUpdateRequest, + RealtyBookingsUpdateRequest, RealtyBookingsQuery, RealtyBookingsResult, + RealtyIntervalsRequest, RealtyMarketPriceInfo, - RealtyRequest, + RealtyPricesUpdateRequest, ) @@ -23,7 +26,7 @@ class ShortTermRentClient: transport: Transport def update_bookings_info( - self, *, user_id: int | str, item_id: int | str, request: RealtyRequest + self, *, user_id: int | str, item_id: int | str, request: RealtyBookingsUpdateRequest ) -> RealtyActionResult: payload = self.transport.request_json( "POST", @@ -45,7 +48,7 @@ def list_realty_bookings( return map_bookings(payload) def update_realty_prices( - self, *, user_id: int | str, item_id: int | str, request: RealtyRequest + self, *, user_id: int | str, item_id: int | str, request: RealtyPricesUpdateRequest ) -> RealtyActionResult: payload = self.transport.request_json( "POST", @@ -55,7 +58,7 @@ def update_realty_prices( ) return map_action(payload) - def get_intervals(self, request: RealtyRequest) -> RealtyActionResult: + def get_intervals(self, request: RealtyIntervalsRequest) -> RealtyActionResult: payload = self.transport.request_json( "POST", "/realty/v1/items/intervals", @@ -65,7 +68,7 @@ def get_intervals(self, request: RealtyRequest) -> RealtyActionResult: return map_action(payload) def update_base_params( - self, *, item_id: int | str, request: RealtyRequest + self, *, item_id: int | str, request: RealtyBaseParamsUpdateRequest ) -> RealtyActionResult: payload = self.transport.request_json( "POST", diff --git a/avito/realty/domain.py b/avito/realty/domain.py index e944ba7..3f7f56e 100644 --- a/avito/realty/domain.py +++ b/avito/realty/domain.py @@ -9,10 +9,13 @@ from avito.realty.models import ( RealtyActionResult, RealtyAnalyticsInfo, + RealtyBaseParamsUpdateRequest, + RealtyBookingsUpdateRequest, RealtyBookingsQuery, RealtyBookingsResult, + RealtyIntervalsRequest, RealtyMarketPriceInfo, - RealtyRequest, + RealtyPricesUpdateRequest, ) @@ -30,11 +33,11 @@ class RealtyListing(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def get_intervals(self, *, request: RealtyRequest) -> RealtyActionResult: + def get_intervals(self, *, request: RealtyIntervalsRequest) -> RealtyActionResult: return ShortTermRentClient(self.transport).get_intervals(request) def update_base_params( - self, *, request: RealtyRequest, item_id: int | str | None = None + self, *, request: RealtyBaseParamsUpdateRequest, item_id: int | str | None = None ) -> RealtyActionResult: return ShortTermRentClient(self.transport).update_base_params( item_id=item_id or self._require_item_id(), @@ -57,7 +60,7 @@ class RealtyBooking(DomainObject): def update_bookings_info( self, *, - request: RealtyRequest, + request: RealtyBookingsUpdateRequest, user_id: int | str | None = None, item_id: int | str | None = None, ) -> RealtyActionResult: @@ -107,7 +110,7 @@ class RealtyPricing(DomainObject): def update_realty_prices( self, *, - request: RealtyRequest, + request: RealtyPricesUpdateRequest, user_id: int | str | None = None, item_id: int | str | None = None, ) -> RealtyActionResult: diff --git a/avito/realty/models.py b/avito/realty/models.py index d1e558c..6eb6a39 100644 --- a/avito/realty/models.py +++ b/avito/realty/models.py @@ -8,23 +8,23 @@ @dataclass(slots=True, frozen=True) -class RealtyRequest: - """Унифицированный typed request для Realty API.""" +class RealtyActionResult(SerializableModel): + """Результат mutation-операции по недвижимости.""" - payload: dict[str, object] + success: bool + status: str | None = None - def to_payload(self) -> dict[str, object]: - """Сериализует JSON payload запроса.""" - return dict(self.payload) +@dataclass(slots=True, frozen=True) +class RealtyBookingsUpdateRequest: + """Запрос обновления занятости по объекту.""" + blocked_dates: list[str] -@dataclass(slots=True, frozen=True) -class RealtyActionResult(SerializableModel): - """Результат mutation-операции по недвижимости.""" + def to_payload(self) -> dict[str, object]: + """Сериализует JSON payload запроса бронирований.""" - success: bool - status: str | None = None + return {"blockedDates": list(self.blocked_dates)} @dataclass(slots=True, frozen=True) @@ -84,6 +84,72 @@ def to_params(self) -> dict[str, str]: return params +@dataclass(slots=True, frozen=True) +class RealtyPricePeriod: + """Период с ценой в запросе обновления цен.""" + + date_from: str + price: int + + def to_payload(self) -> dict[str, object]: + """Сериализует период с ценой.""" + + return {"dateFrom": self.date_from, "price": self.price} + + +@dataclass(slots=True, frozen=True) +class RealtyPricesUpdateRequest: + """Запрос обновления цен по объекту.""" + + periods: list[RealtyPricePeriod] + + def to_payload(self) -> dict[str, object]: + """Сериализует JSON payload запроса цен.""" + + return {"periods": [period.to_payload() for period in self.periods]} + + +@dataclass(slots=True, frozen=True) +class RealtyInterval: + """Интервал доступности объекта.""" + + date: str + available: bool + + def to_payload(self) -> dict[str, object]: + """Сериализует интервал доступности.""" + + return {"date": self.date, "available": self.available} + + +@dataclass(slots=True, frozen=True) +class RealtyIntervalsRequest: + """Запрос заполнения интервалов доступности.""" + + item_id: int + intervals: list[RealtyInterval] + + def to_payload(self) -> dict[str, object]: + """Сериализует JSON payload запроса интервалов.""" + + return { + "itemId": self.item_id, + "intervals": [interval.to_payload() for interval in self.intervals], + } + + +@dataclass(slots=True, frozen=True) +class RealtyBaseParamsUpdateRequest: + """Запрос обновления базовых параметров объекта.""" + + min_stay_days: int + + def to_payload(self) -> dict[str, object]: + """Сериализует JSON payload запроса базовых параметров.""" + + return {"minStayDays": self.min_stay_days} + + @dataclass(slots=True, frozen=True) class RealtyMarketPriceInfo(SerializableModel): """Соответствие цены рыночной стоимости.""" diff --git a/tests/test_public_api_shape.py b/tests/test_public_api_shape.py new file mode 100644 index 0000000..69f99ce --- /dev/null +++ b/tests/test_public_api_shape.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import inspect + +import avito.orders as orders +import avito.autoteka as autoteka +import avito.jobs as jobs +import avito.messenger as messenger +import avito.realty as realty +from avito.autoteka import AutotekaMonitoring, AutotekaReport, AutotekaScoring, AutotekaValuation, AutotekaVehicle +from avito.jobs import Application, JobWebhook, Resume, Vacancy +from avito.messenger import ChatMedia +from avito.orders import DeliveryOrder, Order, OrderLabel, SandboxDelivery, Stock +from avito.realty import RealtyBooking, RealtyListing, RealtyPricing + + +def test_removed_generic_request_wrappers_are_not_exported() -> None: + assert "RealtyRequest" not in realty.__all__ + assert "JobsRequest" not in jobs.__all__ + assert "JobsQuery" not in jobs.__all__ + assert "AutotekaRequest" not in autoteka.__all__ + assert "AutotekaQuery" not in autoteka.__all__ + assert "OrdersRequest" not in orders.__all__ + + +def test_public_signatures_use_typed_requests_instead_of_generic_wrappers() -> None: + methods = ( + RealtyBooking.update_bookings_info, + RealtyListing.get_intervals, + RealtyPricing.update_realty_prices, + Order.update_markings, + Order.apply, + Order.check_confirmation_code, + OrderLabel.create, + DeliveryOrder.create_announcement, + SandboxDelivery.add_areas, + Stock.update, + Application.apply, + Application.list, + JobWebhook.update, + Resume.list, + Vacancy.create, + Vacancy.update, + AutotekaVehicle.create_preview_by_vin, + AutotekaReport.create_report, + AutotekaMonitoring.get_monitoring_reg_actions, + AutotekaScoring.create_scoring_by_vehicle_id, + AutotekaValuation.get_valuation_by_specification, + ) + banned_tokens = ( + "RealtyRequest", + "JobsRequest", + "JobsQuery", + "AutotekaRequest", + "AutotekaQuery", + "OrdersRequest", + ) + + for method in methods: + public_text = str(inspect.signature(method)) + for token in banned_tokens: + assert token not in public_text + + +def test_chat_media_upload_images_no_longer_accepts_raw_dict() -> None: + signature_text = str(inspect.signature(ChatMedia.upload_images)) + assert "dict[str, object]" not in signature_text + assert "UploadImageFile" in signature_text diff --git a/tests/test_stage10_autoteka.py b/tests/test_stage10_autoteka.py index e38e353..2667217 100644 --- a/tests/test_stage10_autoteka.py +++ b/tests/test_stage10_autoteka.py @@ -12,7 +12,21 @@ AutotekaValuation, AutotekaVehicle, ) -from avito.autoteka.models import AutotekaQuery, AutotekaRequest +from avito.autoteka.models import ( + CatalogResolveRequest, + ExternalItemPreviewRequest, + ItemIdRequest, + LeadsRequest, + MonitoringBucketRequest, + MonitoringEventsQuery, + PlateNumberRequest, + PreviewReportRequest, + RegNumberRequest, + TeaserCreateRequest, + ValuationBySpecificationRequest, + VehicleIdRequest, + VinRequest, +) from avito.config import AvitoSettings from avito.core import Transport from avito.core.retries import RetryPolicy @@ -141,25 +155,25 @@ def handler(request: httpx.Request) -> httpx.Response: vehicle = AutotekaVehicle(make_transport(httpx.MockTransport(handler)), resource_id="77") - catalog = vehicle.get_catalogs_resolve(request=AutotekaRequest(payload={"brandId": 1})) - leads = vehicle.get_leads(request=AutotekaRequest(payload={"limit": 1})) - preview_vin = vehicle.create_preview_by_vin(request=AutotekaRequest(payload={"vin": "VIN-1"})) - preview_item = vehicle.create_preview_by_item_id(request=AutotekaRequest(payload={"itemId": 901})) + catalog = vehicle.get_catalogs_resolve(request=CatalogResolveRequest(brand_id=1)) + leads = vehicle.get_leads(request=LeadsRequest(limit=1)) + preview_vin = vehicle.create_preview_by_vin(request=VinRequest(vin="VIN-1")) + preview_item = vehicle.create_preview_by_item_id(request=ItemIdRequest(item_id=901)) preview_reg = vehicle.create_preview_by_reg_number( - request=AutotekaRequest(payload={"regNumber": "A123AA77"}) + request=RegNumberRequest(reg_number="A123AA77") ) preview_external = vehicle.create_preview_by_external_item( - request=AutotekaRequest(payload={"itemId": "ext-1", "site": "cars.example"}) + request=ExternalItemPreviewRequest(item_id="ext-1", site="cars.example") ) preview = vehicle.get_preview() specification_plate = vehicle.create_specification_by_plate_number( - request=AutotekaRequest(payload={"plateNumber": "A123AA77"}) + request=PlateNumberRequest(plate_number="A123AA77") ) specification_vehicle = vehicle.create_specification_by_vehicle_id( - request=AutotekaRequest(payload={"vehicleId": "VIN-1"}) + request=VehicleIdRequest(vehicle_id="VIN-1") ) specification = vehicle.get_specification_by_id(specification_id="501") - teaser_create = vehicle.create_teaser(request=AutotekaRequest(payload={"vehicleId": "VIN-1"})) + teaser_create = vehicle.create_teaser(request=TeaserCreateRequest(vehicle_id="VIN-1")) teaser = vehicle.get_teaser(teaser_id="601") assert catalog.items[0].values[0].label == "Audi" @@ -328,32 +342,30 @@ def handler(request: httpx.Request) -> httpx.Response: valuation = AutotekaValuation(transport) package = report.get_active_package() - created = report.create_report(request=AutotekaRequest(payload={"previewId": 77})) + created = report.create_report(request=PreviewReportRequest(preview_id=77)) created_by_vehicle = report.create_report_by_vehicle_id( - request=AutotekaRequest(payload={"vehicleId": "VIN-1"}) + request=VehicleIdRequest(vehicle_id="VIN-1") ) reports = report.list_report_list() fetched = report.get_report() sync_reg = report.create_sync_report_by_reg_number( - request=AutotekaRequest(payload={"regNumber": "A123AA77"}) - ) - sync_vin = report.create_sync_report_by_vin( - request=AutotekaRequest(payload={"vin": "VIN-1"}) + request=RegNumberRequest(reg_number="A123AA77") ) + sync_vin = report.create_sync_report_by_vin(request=VinRequest(vin="VIN-1")) added = monitoring.create_monitoring_bucket_add( - request=AutotekaRequest(payload={"vehicles": ["VIN-1", "bad-vin"]}) + request=MonitoringBucketRequest(vehicles=["VIN-1", "bad-vin"]) ) deleted = monitoring.list_monitoring_bucket_delete() removed = monitoring.delete_monitoring_bucket_remove( - request=AutotekaRequest(payload={"vehicles": ["VIN-1"]}) + request=MonitoringBucketRequest(vehicles=["VIN-1"]) ) - events = monitoring.get_monitoring_reg_actions(query=AutotekaQuery(params={"limit": 10})) + events = monitoring.get_monitoring_reg_actions(query=MonitoringEventsQuery(limit=10)) scoring_created = scoring.create_scoring_by_vehicle_id( - request=AutotekaRequest(payload={"vehicleId": "VIN-1"}) + request=VehicleIdRequest(vehicle_id="VIN-1") ) scoring_item = scoring.get_scoring_by_id() valuation_item = valuation.get_valuation_by_specification( - request=AutotekaRequest(payload={"specificationId": 501, "mileage": 30000}) + request=ValuationBySpecificationRequest(specification_id=501, mileage=30000) ) assert package.reports_remaining == 77 diff --git a/tests/test_stage11_realty_ratings_tariffs.py b/tests/test_stage11_realty_ratings_tariffs.py index fd47a46..5872648 100644 --- a/tests/test_stage11_realty_ratings_tariffs.py +++ b/tests/test_stage11_realty_ratings_tariffs.py @@ -12,7 +12,14 @@ from avito.ratings import RatingProfile, Review, ReviewAnswer from avito.ratings.models import ReviewsQuery from avito.realty import RealtyAnalyticsReport, RealtyBooking, RealtyListing, RealtyPricing -from avito.realty.models import RealtyRequest +from avito.realty.models import ( + RealtyBaseParamsUpdateRequest, + RealtyBookingsUpdateRequest, + RealtyInterval, + RealtyIntervalsRequest, + RealtyPricePeriod, + RealtyPricesUpdateRequest, +) from avito.tariffs import Tariff @@ -91,7 +98,7 @@ def handler(request: httpx.Request) -> httpx.Response: analytics = RealtyAnalyticsReport(transport, resource_id="20") updated_bookings = booking.update_bookings_info( - request=RealtyRequest(payload={"blockedDates": ["2026-04-18"]}) + request=RealtyBookingsUpdateRequest(blocked_dates=["2026-04-18"]) ) bookings = booking.list_realty_bookings( date_start="2026-05-01", @@ -99,14 +106,17 @@ def handler(request: httpx.Request) -> httpx.Response: with_unpaid=True, ) updated_prices = pricing.update_realty_prices( - request=RealtyRequest(payload={"periods": [{"dateFrom": "2026-05-01", "price": 5000}]}) + request=RealtyPricesUpdateRequest( + periods=[RealtyPricePeriod(date_from="2026-05-01", price=5000)] + ) ) intervals = listing.get_intervals( - request=RealtyRequest( - payload={"itemId": 20, "intervals": [{"date": "2026-05-01", "available": True}]} + request=RealtyIntervalsRequest( + item_id=20, + intervals=[RealtyInterval(date="2026-05-01", available=True)], ) ) - base = listing.update_base_params(request=RealtyRequest(payload={"minStayDays": 2})) + base = listing.update_base_params(request=RealtyBaseParamsUpdateRequest(min_stay_days=2)) market = analytics.get_market_price_correspondence_v1(price=5000000) report = analytics.get_report_for_classified() diff --git a/tests/test_stage5_messenger.py b/tests/test_stage5_messenger.py index 65fc4d1..6126a36 100644 --- a/tests/test_stage5_messenger.py +++ b/tests/test_stage5_messenger.py @@ -10,6 +10,7 @@ from avito.core.retries import RetryPolicy from avito.core.types import ApiTimeouts from avito.messenger import Chat, ChatMedia, ChatMessage, ChatWebhook, SpecialOfferCampaign +from avito.messenger.models import UploadImageFile def make_transport(handler: httpx.MockTransport) -> Transport: @@ -124,7 +125,16 @@ def handler(request: httpx.Request) -> httpx.Response: media = ChatMedia(transport, user_id=7) message = ChatMessage(transport, user_id=7) - uploaded = media.upload_images(files={"image": ("photo.jpg", b"binary", "image/jpeg")}) + uploaded = media.upload_images( + files=[ + UploadImageFile( + field_name="image", + filename="photo.jpg", + content=b"binary", + content_type="image/jpeg", + ) + ] + ) voice_files = media.get_voice_files() sent = message.send_image( chat_id="chat-1", image_id=uploaded.items[0].image_id or "", caption="Фото" diff --git a/tests/test_stage7_orders.py b/tests/test_stage7_orders.py index 8874458..d5cbfc1 100644 --- a/tests/test_stage7_orders.py +++ b/tests/test_stage7_orders.py @@ -10,7 +10,25 @@ from avito.core.retries import RetryPolicy from avito.core.types import ApiTimeouts from avito.orders import DeliveryOrder, DeliveryTask, Order, OrderLabel, SandboxDelivery, Stock -from avito.orders.models import OrdersRequest +from avito.orders.models import ( + DeliveryAnnouncementRequest, + DeliveryParcelIdsRequest, + DeliveryParcelRequest, + DeliveryParcelResultRequest, + OrderAcceptReturnRequest, + OrderApplyTransitionRequest, + OrderCncDetailsRequest, + OrderConfirmationCodeRequest, + OrderCourierRangeRequest, + OrderLabelsRequest, + OrderMarkingsRequest, + OrderTrackingNumberRequest, + SandboxArea, + SandboxAreasRequest, + StockInfoRequest, + StockUpdateEntry, + StockUpdateRequest, +) def make_transport(handler: httpx.MockTransport) -> Transport: @@ -90,21 +108,25 @@ def handler(request: httpx.Request) -> httpx.Response: order = Order(make_transport(httpx.MockTransport(handler)), resource_id="ord-1") orders = order.list() - marked = order.update_markings(request=OrdersRequest(payload={"orderId": "ord-1", "codes": ["abc"]})) - applied = order.apply(request=OrdersRequest(payload={"orderId": "ord-1", "transition": "confirm"})) + marked = order.update_markings(request=OrderMarkingsRequest(order_id="ord-1", codes=["abc"])) + applied = order.apply( + request=OrderApplyTransitionRequest(order_id="ord-1", transition="confirm") + ) code_checked = order.check_confirmation_code( - request=OrdersRequest(payload={"orderId": "ord-1", "code": "1234"}) + request=OrderConfirmationCodeRequest(order_id="ord-1", code="1234") + ) + cnc = order.set_cnc_details( + request=OrderCncDetailsRequest(order_id="ord-1", pickup_point_id="pvz-1") ) - cnc = order.set_cnc_details(request=OrdersRequest(payload={"orderId": "ord-1", "pickupPointId": "pvz-1"})) courier_ranges = order.get_courier_delivery_range() courier_set = order.set_courier_delivery_range( - request=OrdersRequest(payload={"orderId": "ord-1", "intervalId": "int-1"}) + request=OrderCourierRangeRequest(order_id="ord-1", interval_id="int-1") ) tracking = order.update_tracking_number( - request=OrdersRequest(payload={"orderId": "ord-1", "trackingNumber": "TRK-1"}) + request=OrderTrackingNumberRequest(order_id="ord-1", tracking_number="TRK-1") ) returned = order.accept_return_order( - request=OrdersRequest(payload={"orderId": "ord-1", "postalOfficeId": "ops-1"}) + request=OrderAcceptReturnRequest(order_id="ord-1", postal_office_id="ops-1") ) assert orders.items[0].buyer_name == "Иван" @@ -137,7 +159,7 @@ def handler(request: httpx.Request) -> httpx.Response: label = OrderLabel(make_transport(httpx.MockTransport(handler)), resource_id="42") - task = label.create(request=OrdersRequest(payload={"orderIds": ["ord-1"]})) + task = label.create(request=OrderLabelsRequest(order_ids=["ord-1"])) pdf = label.download() assert task.task_id == "42" @@ -201,24 +223,24 @@ def handler(request: httpx.Request) -> httpx.Response: sandbox = SandboxDelivery(transport, resource_id="sand-1") task = DeliveryTask(transport, resource_id="51") - announcement = delivery.create_announcement(request=OrdersRequest(payload={"orderId": "ord-1"})) - parcel = delivery.create(request=OrdersRequest(payload={"orderId": "ord-1", "parcelId": "par-1"})) - cancelled = delivery.delete(request=OrdersRequest(payload={"orderId": "ord-1"})) + announcement = delivery.create_announcement(request=DeliveryAnnouncementRequest(order_id="ord-1")) + parcel = delivery.create(request=DeliveryParcelRequest(order_id="ord-1", parcel_id="par-1")) + cancelled = delivery.delete(request=DeliveryAnnouncementRequest(order_id="ord-1")) callback = delivery.create_change_parcel_result( - request=OrdersRequest(payload={"parcelId": "par-1", "result": "ok"}) + request=DeliveryParcelResultRequest(parcel_id="par-1", result="ok") ) - changed = delivery.update_change_parcels(request=OrdersRequest(payload={"parcelIds": ["par-1"]})) + changed = delivery.update_change_parcels(request=DeliveryParcelIdsRequest(parcel_ids=["par-1"])) sandbox_announcement = sandbox.create_announcement( - request=OrdersRequest(payload={"orderId": "sand-1"}) + request=DeliveryAnnouncementRequest(order_id="sand-1") ) - tracked = sandbox.track_announcement(request=OrdersRequest(payload={"orderId": "sand-1"})) + tracked = sandbox.track_announcement(request=DeliveryAnnouncementRequest(order_id="sand-1")) centers = sandbox.list_sorting_center() added_areas = sandbox.add_areas( tariff_id="tf-1", - request=OrdersRequest(payload={"areas": [{"city": "Москва"}]}), + request=SandboxAreasRequest(areas=[SandboxArea(city="Москва")]), ) sandbox_parcel = sandbox.create_parcel( - request=OrdersRequest(payload={"orderId": "sand-1", "parcelId": "spar-1"}) + request=DeliveryParcelRequest(order_id="sand-1", parcel_id="spar-1") ) task_info = task.get() @@ -269,8 +291,10 @@ def handler(request: httpx.Request) -> httpx.Response: stock = Stock(make_transport(httpx.MockTransport(handler)), resource_id="123321") - info = stock.get(request=OrdersRequest(payload={"itemIds": [123321]})) - updated = stock.update(request=OrdersRequest(payload={"stocks": [{"item_id": 123321, "quantity": 7}]})) + info = stock.get(request=StockInfoRequest(item_ids=[123321])) + updated = stock.update( + request=StockUpdateRequest(stocks=[StockUpdateEntry(item_id=123321, quantity=7)]) + ) assert info.items[0].quantity == 5 assert updated.items[0].external_id == "AB123456" diff --git a/tests/test_stage8_jobs.py b/tests/test_stage8_jobs.py index b6a652b..7259e26 100644 --- a/tests/test_stage8_jobs.py +++ b/tests/test_stage8_jobs.py @@ -10,7 +10,21 @@ from avito.core.retries import RetryPolicy from avito.core.types import ApiTimeouts from avito.jobs import Application, JobDictionary, JobWebhook, Resume, Vacancy -from avito.jobs.models import JobsQuery, JobsRequest +from avito.jobs.models import ( + ApplicationActionRequest, + ApplicationIdsQuery, + ApplicationIdsRequest, + ApplicationViewedItem, + ApplicationViewedRequest, + JobWebhookUpdateRequest, + ResumeSearchQuery, + VacancyArchiveRequest, + VacancyAutoRenewalRequest, + VacancyCreateRequest, + VacancyIdsRequest, + VacancyProlongateRequest, + VacancyUpdateRequest, +) def make_transport(handler: httpx.MockTransport) -> Transport: @@ -89,13 +103,19 @@ def handler(request: httpx.Request) -> httpx.Response: application = Application(transport, resource_id="app-1") webhook = JobWebhook(transport) - ids = application.list(query=JobsQuery(params={"updatedAtFrom": "2026-04-18"})) - applications = application.list(request=JobsRequest(payload={"ids": ["app-1"]})) + ids = application.list(query=ApplicationIdsQuery(updated_at_from="2026-04-18")) + applications = application.list(request=ApplicationIdsRequest(ids=["app-1"])) states = application.get_states() - viewed = application.update(request=JobsRequest(payload={"applies": [{"id": "app-1", "is_viewed": True}]})) - applied = application.apply(request=JobsRequest(payload={"ids": ["app-1"], "action": "invited"})) + viewed = application.update( + request=ApplicationViewedRequest( + applies=[ApplicationViewedItem(id="app-1", is_viewed=True)] + ) + ) + applied = application.apply(request=ApplicationActionRequest(ids=["app-1"], action="invited")) current_hook = webhook.get() - updated_hook = webhook.update(request=JobsRequest(payload={"url": "https://example.com/job"})) + updated_hook = webhook.update( + request=JobWebhookUpdateRequest(url="https://example.com/job") + ) deleted_hook = webhook.delete(url="https://example.com/job") hooks = webhook.list() @@ -147,7 +167,7 @@ def handler(request: httpx.Request) -> httpx.Response: resume = Resume(make_transport(httpx.MockTransport(handler)), resource_id="res-1") - results = resume.list(query=JobsQuery(params={"query": "оператор"})) + results = resume.list(query=ResumeSearchQuery(query="оператор")) contacts = resume.get_contacts() item = resume.get() @@ -226,25 +246,25 @@ def handler(request: httpx.Request) -> httpx.Response: vacancy = Vacancy(make_transport(httpx.MockTransport(handler)), resource_id="101") - created_v1 = vacancy.create(request=JobsRequest(payload={"title": "Продавец"}), version=1) + created_v1 = vacancy.create(request=VacancyCreateRequest(title="Продавец"), version=1) updated_v1 = vacancy.update( - request=JobsRequest(payload={"title": "Старший продавец"}), + request=VacancyUpdateRequest(title="Старший продавец"), version=1, ) - archived_v1 = vacancy.delete(request=JobsRequest(payload={"employee_id": 7})) - prolonged_v1 = vacancy.prolongate(request=JobsRequest(payload={"billing_type": "package"})) + archived_v1 = vacancy.delete(request=VacancyArchiveRequest(employee_id=7)) + prolonged_v1 = vacancy.prolongate(request=VacancyProlongateRequest(billing_type="package")) list_v2 = vacancy.list() - created_v2 = vacancy.create(request=JobsRequest(payload={"title": "Вакансия v2"})) - batch_v2 = vacancy.get_by_ids(request=JobsRequest(payload={"ids": [101]})) - statuses_v2 = vacancy.get_statuses(request=JobsRequest(payload={"ids": [101]})) + created_v2 = vacancy.create(request=VacancyCreateRequest(title="Вакансия v2")) + batch_v2 = vacancy.get_by_ids(request=VacancyIdsRequest(ids=[101])) + statuses_v2 = vacancy.get_statuses(request=VacancyIdsRequest(ids=[101])) updated_v2 = vacancy.update( - request=JobsRequest(payload={"title": "Вакансия v2 updated"}), + request=VacancyUpdateRequest(title="Вакансия v2 updated"), version=2, vacancy_uuid="vac-uuid-1", ) item_v2 = vacancy.get() auto_renewal = vacancy.update_auto_renewal( - request=JobsRequest(payload={"auto_renewal": True}), + request=VacancyAutoRenewalRequest(auto_renewal=True), vacancy_uuid="vac-uuid-1", ) From 18491be8d41fc01ecc035c611642f4e1c9ac5577 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Sun, 19 Apr 2026 01:12:58 +0300 Subject: [PATCH 03/17] =?UTF-8?q?=D0=98=D0=B4=D1=83=20=D0=BA=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B3=D0=BE=D0=BC=D1=83=20=D0=BE=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B0=D0=BD=D0=B8=D1=8E=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- avito/accounts/mappers.py | 18 +- avito/accounts/models.py | 15 +- avito/ads/client.py | 18 +- avito/ads/mappers.py | 42 +- avito/ads/models.py | 30 - avito/auth/mappers.py | 1 - avito/auth/models.py | 2 - avito/auth/settings.py | 10 +- avito/autoteka/__init__.py | 2 +- avito/autoteka/client.py | 8 +- avito/autoteka/domain.py | 12 +- avito/autoteka/mappers.py | 18 +- avito/autoteka/models.py | 20 +- avito/core/exceptions.py | 4 +- avito/core/transport.py | 4 +- avito/cpa/domain.py | 4 +- avito/cpa/models.py | 2 +- avito/jobs/__init__.py | 8 +- avito/jobs/client.py | 8 +- avito/jobs/domain.py | 8 +- avito/jobs/mappers.py | 24 +- avito/jobs/models.py | 23 +- avito/messenger/mappers.py | 17 - avito/messenger/models.py | 23 +- avito/orders/__init__.py | 92 +- avito/orders/client.py | 102 ++- avito/orders/domain.py | 89 +- avito/orders/mappers.py | 14 - avito/orders/models.py | 841 +++++++++++++++++- avito/promotion/client.py | 28 +- avito/promotion/domain.py | 4 +- avito/promotion/mappers.py | 48 +- avito/promotion/models.py | 26 +- avito/ratings/domain.py | 4 +- avito/ratings/mappers.py | 4 - avito/ratings/models.py | 7 +- avito/realty/client.py | 2 +- avito/realty/domain.py | 2 +- avito/tariffs/mappers.py | 2 - avito/tariffs/models.py | 5 +- tests/fake_transport.py | 8 +- tests/test_core.py | 4 +- tests/test_no_raw_payload_contract.py | 2 +- tests/test_promotion_contract_alignment.py | 4 +- tests/test_public_api_shape.py | 11 +- tests/test_public_models.py | 6 - tests/test_read_contract.py | 4 +- tests/test_realty_contract_alignment.py | 4 +- tests/test_stage11_mock_transport_suite.py | 37 +- tests/test_stage11_realty_ratings_tariffs.py | 6 +- tests/test_stage4_promotion_write_contract.py | 35 +- tests/test_stage6_promotion.py | 16 +- tests/test_stage7_orders.py | 452 +++++++++- tests/test_stage8_jobs.py | 4 +- tests/test_stage8_serialization_contract.py | 5 - tests/test_stage9_transport_isolation.py | 4 +- 56 files changed, 1715 insertions(+), 478 deletions(-) diff --git a/avito/accounts/mappers.py b/avito/accounts/mappers.py index 5d630d2..b037128 100644 --- a/avito/accounts/mappers.py +++ b/avito/accounts/mappers.py @@ -83,7 +83,6 @@ def map_account_profile(payload: object) -> AccountProfile: name=_as_str(data, "name", "title"), email=_as_str(data, "email"), phone=_as_str(data, "phone"), - _payload=data, ) @@ -105,7 +104,6 @@ def map_account_balance(payload: object) -> AccountBalance: bonus=bonus, total=total, currency=_as_str(wallet_data, "currency"), - _payload=data, ) @@ -121,14 +119,12 @@ def map_operations_history(payload: object) -> OperationsHistoryResult: operation_type=_as_str(item, "type", "operation_type", "operationType"), status=_as_str(item, "status"), description=_as_str(item, "description", "title"), - _payload=item, ) for item in _as_list(data, "operations", "items", "result") ] return OperationsHistoryResult( operations=operations, total=_as_int(data, "total", "count"), - _payload=data, ) @@ -140,7 +136,6 @@ def map_ah_user_status(payload: object) -> AhUserStatus: user_id=_as_int(data, "user_id", "userId", "id"), is_active=_as_bool(data, "is_active", "isActive", "active"), role=_as_str(data, "role", "status"), - _payload=data, ) @@ -155,11 +150,10 @@ def map_employees(payload: object) -> EmployeesResult: name=_as_str(item, "name", "title"), phone=_as_str(item, "phone"), email=_as_str(item, "email"), - _payload=item, ) for item in _as_list(data, "employees", "items", "result") ] - return EmployeesResult(items=items, total=_as_int(data, "total", "count"), _payload=data) + return EmployeesResult(items=items, total=_as_int(data, "total", "count")) def map_company_phones(payload: object) -> CompanyPhonesResult: @@ -171,11 +165,10 @@ def map_company_phones(payload: object) -> CompanyPhonesResult: id=_as_int(item, "id", "phone_id", "phoneId"), phone=_as_str(item, "phone", "value"), comment=_as_str(item, "comment", "description"), - _payload=item, ) for item in _as_list(data, "phones", "items", "result") ] - return CompanyPhonesResult(items=items, _payload=data) + return CompanyPhonesResult(items=items) def map_employee_items(payload: object) -> EmployeeItemsResult: @@ -188,11 +181,10 @@ def map_employee_items(payload: object) -> EmployeeItemsResult: title=_as_str(item, "title"), status=_as_str(item, "status"), price=_as_float(item, "price"), - _payload=item, ) for item in _as_list(data, "items", "result") ] - return EmployeeItemsResult(items=items, total=_as_int(data, "total", "count"), _payload=data) + return EmployeeItemsResult(items=items, total=_as_int(data, "total", "count")) def map_action_result(payload: object) -> ActionResult: @@ -202,8 +194,8 @@ def map_action_result(payload: object) -> ActionResult: data = cast(Payload, payload) success = bool(data.get("success", True)) message = _as_str(data, "message", "status") - return ActionResult(success=success, message=message, _payload=data) - return ActionResult(success=True, _payload={}) + return ActionResult(success=success, message=message) + return ActionResult(success=True) __all__ = ( diff --git a/avito/accounts/models.py b/avito/accounts/models.py index 394c04e..0bcb32c 100644 --- a/avito/accounts/models.py +++ b/avito/accounts/models.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass from avito.core.serialization import SerializableModel, enable_module_serialization @@ -16,7 +15,6 @@ class AccountProfile(SerializableModel): name: str | None email: str | None phone: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -28,7 +26,6 @@ class AccountBalance: bonus: float | None total: float | None currency: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -41,7 +38,6 @@ class OperationRecord: operation_type: str | None status: str | None description: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -74,7 +70,6 @@ class OperationsHistoryResult(SerializableModel): operations: list[OperationRecord] total: int | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -84,7 +79,6 @@ class AhUserStatus: user_id: int | None is_active: bool | None role: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -96,7 +90,6 @@ class Employee: name: str | None phone: str | None email: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -105,7 +98,6 @@ class EmployeesResult: items: list[Employee] total: int | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -115,7 +107,6 @@ class CompanyPhone: id: int | None phone: str | None comment: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -123,7 +114,6 @@ class CompanyPhonesResult: """Список телефонов компании.""" items: list[CompanyPhone] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -178,7 +168,6 @@ class EmployeeItem: title: str | None status: str | None price: float | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -187,7 +176,6 @@ class EmployeeItemsResult: items: list[EmployeeItem] total: int | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -196,7 +184,6 @@ class ActionResult: success: bool message: str | None = None - _payload: Mapping[str, object] = field(default_factory=dict) __all__ = ( diff --git a/avito/ads/client.py b/avito/ads/client.py index 4d1b810..256827c 100644 --- a/avito/ads/client.py +++ b/avito/ads/client.py @@ -105,7 +105,11 @@ def list_items( page_size = limit if limit and limit > 0 else len(result.items) resolved_offset = start_offset or 0 - if result.total is None or page_size <= 0 or resolved_offset + len(result.items) >= result.total: + if ( + result.total is None + or page_size <= 0 + or resolved_offset + len(result.items) >= result.total + ): return result start_page = resolved_offset // page_size + 1 @@ -258,7 +262,9 @@ def apply_item_vas( ) -> PromotionActionResult: """Применяет дополнительные услуги к объявлению.""" - payload_to_send = dict(request_payload) if request_payload is not None else request.to_payload() + payload_to_send = ( + dict(request_payload) if request_payload is not None else request.to_payload() + ) payload = self.transport.request_json( "PUT", f"/core/v1/accounts/{user_id}/items/{item_id}/vas", @@ -284,7 +290,9 @@ def apply_item_vas_package( ) -> PromotionActionResult: """Применяет пакет дополнительных услуг.""" - payload_to_send = dict(request_payload) if request_payload is not None else request.to_payload() + payload_to_send = ( + dict(request_payload) if request_payload is not None else request.to_payload() + ) payload = self.transport.request_json( "PUT", f"/core/v2/accounts/{user_id}/items/{item_id}/vas_packages", @@ -309,7 +317,9 @@ def apply_vas_v2( ) -> PromotionActionResult: """Применяет услуги продвижения через v2 endpoint.""" - payload_to_send = dict(request_payload) if request_payload is not None else request.to_payload() + payload_to_send = ( + dict(request_payload) if request_payload is not None else request.to_payload() + ) payload = self.transport.request_json( "PUT", f"/core/v2/items/{item_id}/vas/", diff --git a/avito/ads/mappers.py b/avito/ads/mappers.py index 34ed058..6752c0f 100644 --- a/avito/ads/mappers.py +++ b/avito/ads/mappers.py @@ -103,7 +103,6 @@ def map_ad_item(payload: object) -> AdItem: status=_str(data, "status"), price=_float(data, "price"), url=_str(data, "url", "link"), - _payload=data, ) @@ -112,7 +111,7 @@ def map_ads_list(payload: object) -> AdsListResult: data = _expect_mapping(payload) items = [map_ad_item(item) for item in _list(data, "items", "result", "resources")] - return AdsListResult(items=items, total=_int(data, "total", "count"), _payload=data) + return AdsListResult(items=items, total=_int(data, "total", "count")) def map_update_price_result(payload: object) -> UpdatePriceResult: @@ -123,7 +122,6 @@ def map_update_price_result(payload: object) -> UpdatePriceResult: item_id=_int(data, "item_id", "itemId", "id"), price=_float(data, "price"), status=_str(data, "status", "result"), - _payload=data, ) @@ -137,11 +135,10 @@ def map_calls_stats(payload: object) -> CallsStatsResult: calls=_int(item, "calls", "total"), answered_calls=_int(item, "answered_calls", "answeredCalls"), missed_calls=_int(item, "missed_calls", "missedCalls"), - _payload=item, ) for item in _list(data, "items", "result", "stats") ] - return CallsStatsResult(items=items, _payload=data) + return CallsStatsResult(items=items) def _map_item_stat(item: Payload) -> ItemStatsRecord: @@ -150,7 +147,6 @@ def _map_item_stat(item: Payload) -> ItemStatsRecord: views=_int(item, "views", "impressions"), contacts=_int(item, "contacts", "contacts_total", "contactsTotal"), favorites=_int(item, "favorites", "favorites_total", "favoritesTotal"), - _payload=item, ) @@ -160,7 +156,6 @@ def map_item_stats(payload: object) -> ItemStatsResult: data = _expect_mapping(payload) return ItemStatsResult( items=[_map_item_stat(item) for item in _list(data, "items", "result", "stats")], - _payload=data, ) @@ -171,7 +166,6 @@ def map_item_analytics(payload: object) -> ItemAnalyticsResult: return ItemAnalyticsResult( items=[_map_item_stat(item) for item in _list(data, "items", "result", "stats")], period=_str(data, "period"), - _payload=data, ) @@ -184,14 +178,13 @@ def map_spendings(payload: object) -> SpendingsResult: item_id=_int(item, "item_id", "itemId", "id"), amount=_float(item, "amount", "price", "cost"), service=_str(item, "service", "serviceType", "type"), - _payload=item, ) for item in _list(data, "items", "result", "spendings") ] total = _float(data, "total") if total is None: total = sum(item.amount for item in items if item.amount is not None) or None - return SpendingsResult(items=items, total=total, _payload=data) + return SpendingsResult(items=items, total=total) def map_vas_prices(payload: object) -> VasPricesResult: @@ -204,11 +197,10 @@ def map_vas_prices(payload: object) -> VasPricesResult: title=_str(item, "title", "name"), price=_float(item, "price", "amount"), is_available=_bool(item, "is_available", "isAvailable", "available"), - _payload=item, ) for item in _list(data, "items", "services", "result") ] - return VasPricesResult(items=items, _payload=data) + return VasPricesResult(items=items) def map_vas_apply_result(payload: object) -> VasApplyResult: @@ -218,7 +210,6 @@ def map_vas_apply_result(payload: object) -> VasApplyResult: return VasApplyResult( success=bool(data.get("success", True)), status=_str(data, "status", "result", "message"), - _payload=data, ) @@ -230,7 +221,6 @@ def map_autoload_profile(payload: object) -> AutoloadProfileSettings: user_id=_int(data, "user_id", "userId", "id"), is_enabled=_bool(data, "is_enabled", "isEnabled", "enabled"), upload_url=_str(data, "upload_url", "uploadUrl", "url"), - _payload=data, ) @@ -241,7 +231,6 @@ def map_upload_result(payload: object) -> UploadResult: return UploadResult( success=bool(data.get("success", True)), report_id=_int(data, "report_id", "reportId", "id"), - _payload=data, ) @@ -255,11 +244,10 @@ def map_autoload_fields(payload: object) -> AutoloadFieldsResult: title=_str(item, "title", "name"), type=_str(item, "type"), required=_bool(item, "required", "is_required", "isRequired"), - _payload=item, ) for item in _list(data, "fields", "items", "result") ] - return AutoloadFieldsResult(items=items, _payload=data) + return AutoloadFieldsResult(items=items) def _map_tree_node(payload: Payload) -> AutoloadTreeNode: @@ -267,7 +255,6 @@ def _map_tree_node(payload: Payload) -> AutoloadTreeNode: slug=_str(payload, "slug", "code", "id"), title=_str(payload, "title", "name"), children=[_map_tree_node(item) for item in _list(payload, "children", "items")], - _payload=payload, ) @@ -276,7 +263,7 @@ def map_autoload_tree(payload: object) -> AutoloadTreeResult: data = _expect_mapping(payload) items = [_map_tree_node(item) for item in _list(data, "tree", "items", "result")] - return AutoloadTreeResult(items=items, _payload=data) + return AutoloadTreeResult(items=items) def map_id_mapping(payload: object) -> IdMappingResult: @@ -286,7 +273,7 @@ def map_id_mapping(payload: object) -> IdMappingResult: mappings: list[tuple[int | None, int | None]] = [] for item in _list(data, "items", "result", "mappings"): mappings.append((_int(item, "ad_id", "adId"), _int(item, "avito_id", "avitoId"))) - return IdMappingResult(mappings=mappings, _payload=data) + return IdMappingResult(mappings=mappings) def _map_report_summary(item: Payload) -> AutoloadReportSummary: @@ -296,7 +283,6 @@ def _map_report_summary(item: Payload) -> AutoloadReportSummary: created_at=_str(item, "created_at", "createdAt"), finished_at=_str(item, "finished_at", "finishedAt"), processed_items=_int(item, "processed_items", "processedItems", "items"), - _payload=item, ) @@ -307,7 +293,6 @@ def map_autoload_reports(payload: object) -> AutoloadReportsResult: return AutoloadReportsResult( items=[_map_report_summary(item) for item in _list(data, "reports", "items", "result")], total=_int(data, "total", "count"), - _payload=data, ) @@ -322,7 +307,6 @@ def map_autoload_report_details(payload: object) -> AutoloadReportDetails: finished_at=_str(data, "finished_at", "finishedAt"), errors_count=_int(data, "errors_count", "errorsCount"), warnings_count=_int(data, "warnings_count", "warningsCount"), - _payload=data, ) @@ -333,7 +317,6 @@ def map_legacy_autoload_report(payload: object) -> LegacyAutoloadReport: return LegacyAutoloadReport( report_id=_int(data, "report_id", "reportId", "id"), status=_str(data, "status"), - _payload=data, ) @@ -347,13 +330,10 @@ def map_autoload_report_items(payload: object) -> AutoloadReportItemsResult: avito_id=_int(item, "avito_id", "avitoId"), status=_str(item, "status"), title=_str(item, "title"), - _payload=item, ) for item in _list(data, "items", "result") ] - return AutoloadReportItemsResult( - items=items, total=_int(data, "total", "count"), _payload=data - ) + return AutoloadReportItemsResult(items=items, total=_int(data, "total", "count")) def map_autoload_fees(payload: object) -> AutoloadFeesResult: @@ -365,14 +345,13 @@ def map_autoload_fees(payload: object) -> AutoloadFeesResult: item_id=_int(item, "item_id", "itemId", "id"), amount=_float(item, "amount", "price", "cost"), service=_str(item, "service", "serviceType", "type"), - _payload=item, ) for item in _list(data, "items", "result", "fees") ] total = _float(data, "total") if total is None: total = sum(item.amount for item in items if item.amount is not None) or None - return AutoloadFeesResult(items=items, total=total, _payload=data) + return AutoloadFeesResult(items=items, total=total) def map_action_result(payload: object) -> ActionResult: @@ -383,9 +362,8 @@ def map_action_result(payload: object) -> ActionResult: return ActionResult( success=bool(data.get("success", True)), message=_str(data, "message", "status"), - _payload=data, ) - return ActionResult(success=True, _payload={}) + return ActionResult(success=True) __all__ = ( diff --git a/avito/ads/models.py b/avito/ads/models.py index 4dae8bb..e9338b0 100644 --- a/avito/ads/models.py +++ b/avito/ads/models.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass, field from avito.core.serialization import SerializableModel, enable_module_serialization @@ -19,7 +18,6 @@ class AdItem(SerializableModel): status: str | None price: float | None url: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -28,7 +26,6 @@ class AdsListResult(SerializableModel): items: list[AdItem] total: int | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -50,7 +47,6 @@ class UpdatePriceResult: item_id: int | None price: float | None status: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -83,7 +79,6 @@ class CallStat(SerializableModel): calls: int | None answered_calls: int | None missed_calls: int | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -91,7 +86,6 @@ class CallsStatsResult(SerializableModel): """Статистика звонков по набору объявлений.""" items: list[CallStat] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -126,7 +120,6 @@ class ItemStatsRecord(SerializableModel): views: int | None contacts: int | None favorites: int | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -134,7 +127,6 @@ class ItemStatsResult(SerializableModel): """Статистика по списку объявлений.""" items: list[ItemStatsRecord] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -143,7 +135,6 @@ class ItemAnalyticsResult: items: list[ItemStatsRecord] period: str | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -153,7 +144,6 @@ class SpendingRecord(SerializableModel): item_id: int | None amount: float | None service: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -162,7 +152,6 @@ class SpendingsResult(SerializableModel): items: list[SpendingRecord] total: float | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -173,7 +162,6 @@ class VasPrice: title: str | None price: float | None is_available: bool | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -201,7 +189,6 @@ class VasPricesResult: """Список цен и доступных услуг продвижения.""" items: list[VasPrice] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -210,7 +197,6 @@ class VasApplyResult: success: bool status: str | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -244,7 +230,6 @@ class AutoloadProfileSettings: user_id: int | None is_enabled: bool | None upload_url: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -287,7 +272,6 @@ class UploadResult: success: bool report_id: int | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -298,7 +282,6 @@ class AutoloadField: title: str | None type: str | None required: bool | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -306,7 +289,6 @@ class AutoloadFieldsResult: """Список полей категории автозагрузки.""" items: list[AutoloadField] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -316,7 +298,6 @@ class AutoloadTreeNode: slug: str | None title: str | None children: list[AutoloadTreeNode] = field(default_factory=list) - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -324,7 +305,6 @@ class AutoloadTreeResult: """Дерево категорий автозагрузки.""" items: list[AutoloadTreeNode] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -332,7 +312,6 @@ class IdMappingResult: """Сопоставление идентификаторов объявлений.""" mappings: list[tuple[int | None, int | None]] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -344,7 +323,6 @@ class AutoloadReportSummary: created_at: str | None finished_at: str | None processed_items: int | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -353,7 +331,6 @@ class AutoloadReportsResult: items: list[AutoloadReportSummary] total: int | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -364,7 +341,6 @@ class AutoloadReportItem: avito_id: int | None status: str | None title: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -373,7 +349,6 @@ class AutoloadReportItemsResult: items: list[AutoloadReportItem] total: int | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -383,7 +358,6 @@ class AutoloadFee: item_id: int | None amount: float | None service: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -392,7 +366,6 @@ class AutoloadFeesResult: items: list[AutoloadFee] total: float | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -405,7 +378,6 @@ class AutoloadReportDetails: finished_at: str | None errors_count: int | None warnings_count: int | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -414,7 +386,6 @@ class LegacyAutoloadReport: report_id: int | None status: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -423,7 +394,6 @@ class ActionResult: success: bool message: str | None = None - _payload: Mapping[str, object] = field(default_factory=dict) Listing = AdItem diff --git a/avito/auth/mappers.py b/avito/auth/mappers.py index e489366..fbd034e 100644 --- a/avito/auth/mappers.py +++ b/avito/auth/mappers.py @@ -39,7 +39,6 @@ def map_token_response(payload: object, *, now: datetime | None = None) -> Token ), refresh_token=refresh_token, scope=payload.get("scope") if isinstance(payload.get("scope"), str) else None, - _payload=payload, ) diff --git a/avito/auth/models.py b/avito/auth/models.py index 262065c..5570c56 100644 --- a/avito/auth/models.py +++ b/avito/auth/models.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime, timedelta @@ -28,7 +27,6 @@ class TokenResponse: access_token: AccessToken refresh_token: str | None = None scope: str | None = None - _payload: Mapping[str, object] | None = None @dataclass(slots=True, frozen=True) diff --git a/avito/auth/settings.py b/avito/auth/settings.py index 8310d6e..dae817a 100644 --- a/avito/auth/settings.py +++ b/avito/auth/settings.py @@ -149,15 +149,9 @@ def validate_required(self) -> AuthSettings: missing_fields: list[str] = [] if not self.client_id: - missing_fields.append( - "client_id: " - + ", ".join(self.ENV_ALIASES["client_id"]) - ) + missing_fields.append("client_id: " + ", ".join(self.ENV_ALIASES["client_id"])) if not self.client_secret: - missing_fields.append( - "client_secret: " - + ", ".join(self.ENV_ALIASES["client_secret"]) - ) + missing_fields.append("client_secret: " + ", ".join(self.ENV_ALIASES["client_secret"])) if missing_fields: raise ConfigurationError( "Не заданы обязательные настройки OAuth. Ожидаются " diff --git a/avito/autoteka/__init__.py b/avito/autoteka/__init__.py index 4cd82af..437c091 100644 --- a/avito/autoteka/__init__.py +++ b/avito/autoteka/__init__.py @@ -21,8 +21,8 @@ AutotekaValuationInfo, CatalogField, CatalogFieldValue, - CatalogResolveResult, CatalogResolveRequest, + CatalogResolveResult, ExternalItemPreviewRequest, ItemIdRequest, LeadsRequest, diff --git a/avito/autoteka/client.py b/avito/autoteka/client.py index 360eb2b..f3ad9a1 100644 --- a/avito/autoteka/client.py +++ b/avito/autoteka/client.py @@ -28,14 +28,14 @@ AutotekaSpecificationInfo, AutotekaTeaserInfo, AutotekaValuationInfo, - CatalogResolveResult, CatalogResolveRequest, + CatalogResolveResult, ExternalItemPreviewRequest, ItemIdRequest, LeadsRequest, MonitoringBucketRequest, - MonitoringEventsQuery, MonitoringBucketResult, + MonitoringEventsQuery, MonitoringEventsResult, PlateNumberRequest, PreviewReportRequest, @@ -356,7 +356,9 @@ def get(self, *, teaser_id: int | str) -> AutotekaTeaserInfo: class ValuationClient(AutotekaBaseClient): """Выполняет HTTP-операции оценки стоимости.""" - def get_by_specification(self, request: ValuationBySpecificationRequest) -> AutotekaValuationInfo: + def get_by_specification( + self, request: ValuationBySpecificationRequest + ) -> AutotekaValuationInfo: payload = self.transport.request_json( "POST", "/autoteka/v1/valuation/by-specification", diff --git a/avito/autoteka/domain.py b/avito/autoteka/domain.py index 58c13d1..a3dbb76 100644 --- a/avito/autoteka/domain.py +++ b/avito/autoteka/domain.py @@ -25,14 +25,14 @@ AutotekaSpecificationInfo, AutotekaTeaserInfo, AutotekaValuationInfo, - CatalogResolveResult, CatalogResolveRequest, + CatalogResolveResult, ExternalItemPreviewRequest, ItemIdRequest, LeadsRequest, MonitoringBucketRequest, - MonitoringEventsQuery, MonitoringBucketResult, + MonitoringEventsQuery, MonitoringEventsResult, PlateNumberRequest, PreviewReportRequest, @@ -141,14 +141,10 @@ def get_report(self, *, report_id: int | str | None = None) -> AutotekaReportInf report_id=report_id or self._require_resource_id() ) - def create_sync_report_by_reg_number( - self, *, request: RegNumberRequest - ) -> AutotekaReportInfo: + def create_sync_report_by_reg_number(self, *, request: RegNumberRequest) -> AutotekaReportInfo: return ReportClient(self.transport).create_sync_report_by_reg_number(request) - def create_sync_report_by_vin( - self, *, request: VinRequest - ) -> AutotekaReportInfo: + def create_sync_report_by_vin(self, *, request: VinRequest) -> AutotekaReportInfo: return ReportClient(self.transport).create_sync_report_by_vin(request) def _require_resource_id(self) -> str: diff --git a/avito/autoteka/mappers.py b/avito/autoteka/mappers.py index 44a25c9..6f1c90d 100644 --- a/avito/autoteka/mappers.py +++ b/avito/autoteka/mappers.py @@ -94,15 +94,12 @@ def map_catalogs_resolve(payload: object) -> CatalogResolveResult: CatalogFieldValue( value_id=_str(value, "valueId", "id"), label=_str(value, "label", "value"), - _payload=value, ) for value in _list(item, "values", "items") ], - _payload=item, ) for item in _list(result, "fields", "items") ], - _payload=data, ) @@ -125,10 +122,9 @@ def map_leads(payload: object) -> AutotekaLeadsResult: price=_int(event_payload, "price"), created_at=_str(event_payload, "itemCreatedAt"), url=_str(event_payload, "url"), - _payload=item, ) ) - return AutotekaLeadsResult(items=items, last_id=_int(pagination, "lastId"), _payload=data) + return AutotekaLeadsResult(items=items, last_id=_int(pagination, "lastId")) def map_monitoring_bucket(payload: object) -> MonitoringBucketResult: @@ -142,11 +138,9 @@ def map_monitoring_bucket(payload: object) -> MonitoringBucketResult: MonitoringInvalidVehicle( vehicle_id=_str(item, "vehicleID", "vehicleId"), description=_str(item, "description"), - _payload=item, ) for item in _list(result, "invalidVehicles", "items") ], - _payload=data, ) @@ -167,14 +161,12 @@ def map_monitoring_events(payload: object) -> MonitoringEventsResult: operation_date_to=_str(item, "operationDateTo"), owner_code=_int(item, "ownerCode"), actual_at=_int(item, "actualAt"), - _payload=item, ) for item in _list(data, "data", "items") ], has_next=_bool(pagination, "hasNext"), next_cursor=_str(pagination, "nextCursor"), next_link=_str(pagination, "nextLink"), - _payload=data, ) @@ -189,7 +181,6 @@ def map_package(payload: object) -> AutotekaPackageInfo: reports_remaining=_int(package, "reportsCntRemain"), created_at=_str(package, "createdTime"), expires_at=_str(package, "expireTime"), - _payload=data, ) @@ -199,7 +190,6 @@ def _map_preview_source(source: Payload) -> AutotekaPreviewInfo: status=_str(source, "status"), vehicle_id=_str(source, "vin", "vehicleId"), reg_number=_str(source, "regNumber", "plateNumber"), - _payload=source, ) @@ -222,7 +212,6 @@ def _map_report_source(source: Payload) -> AutotekaReportInfo: created_at=_str(source, "createdAt") or _str(data, "createdAt"), web_link=_str(source, "webLink"), pdf_link=_str(source, "pdfLink"), - _payload=source, ) @@ -242,7 +231,6 @@ def map_reports(payload: object) -> AutotekaReportsResult: data = _expect_mapping(payload) return AutotekaReportsResult( items=[_map_report_source(item) for item in _list(data, "result", "items")], - _payload=data, ) @@ -257,7 +245,6 @@ def map_scoring(payload: object) -> AutotekaScoringInfo: scoring_id=_str(source, "scoringId"), is_completed=_bool(source, "isCompleted"), created_at=_int(source, "createdAt"), - _payload=source, ) @@ -273,7 +260,6 @@ def map_specification(payload: object) -> AutotekaSpecificationInfo: status=_str(source, "status"), vehicle_id=_str(source, "vehicleId"), plate_number=_str(source, "plateNumber"), - _payload=source, ) @@ -291,7 +277,6 @@ def map_teaser(payload: object) -> AutotekaTeaserInfo: brand=_str(teaser_data, "brand") if teaser_data else _str(source, "brand"), model=_str(teaser_data, "model") if teaser_data else _str(source, "model"), year=_int(teaser_data, "year") if teaser_data else _int(source, "year"), - _payload=data, ) @@ -312,5 +297,4 @@ def map_valuation(payload: object) -> AutotekaValuationInfo: mileage=_int(source, "mileage"), avg_price_with_condition=_int(valuation, "avgPriceWithCondition"), avg_market_price=_int(valuation, "avgMarketPrice"), - _payload=data, ) diff --git a/avito/autoteka/models.py b/avito/autoteka/models.py index 0d1e1d7..a603851 100644 --- a/avito/autoteka/models.py +++ b/avito/autoteka/models.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass from avito.core.serialization import enable_module_serialization @@ -175,7 +174,6 @@ class CatalogFieldValue: value_id: str | None label: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -186,7 +184,6 @@ class CatalogField: label: str | None data_type: str | None values: list[CatalogFieldValue] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -194,7 +191,6 @@ class CatalogResolveResult: """Результат актуализации параметров автокаталога.""" items: list[CatalogField] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -210,7 +206,6 @@ class AutotekaLeadEvent: price: int | None created_at: str | None url: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -219,7 +214,6 @@ class AutotekaLeadsResult: items: list[AutotekaLeadEvent] last_id: int | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -228,7 +222,6 @@ class MonitoringInvalidVehicle: vehicle_id: str | None description: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -237,7 +230,6 @@ class MonitoringBucketResult: success: bool invalid_vehicles: list[MonitoringInvalidVehicle] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -253,7 +245,6 @@ class MonitoringEvent: operation_date_to: str | None owner_code: int | None actual_at: int | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -264,7 +255,6 @@ class MonitoringEventsResult: has_next: bool | None = None next_cursor: str | None = None next_link: str | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -275,7 +265,6 @@ class AutotekaPackageInfo: reports_remaining: int | None created_at: str | None expires_at: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -286,7 +275,6 @@ class AutotekaPreviewInfo: status: str | None vehicle_id: str | None reg_number: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -299,7 +287,6 @@ class AutotekaReportInfo: created_at: str | None web_link: str | None pdf_link: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -307,7 +294,6 @@ class AutotekaReportsResult: """Список отчетов Автотеки.""" items: list[AutotekaReportInfo] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -317,7 +303,6 @@ class AutotekaScoringInfo: scoring_id: str | None is_completed: bool | None created_at: int | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -328,7 +313,6 @@ class AutotekaSpecificationInfo: status: str | None vehicle_id: str | None plate_number: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -340,7 +324,6 @@ class AutotekaTeaserInfo: brand: str | None = None model: str | None = None year: int | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -356,7 +339,6 @@ class AutotekaValuationInfo: mileage: int | None avg_price_with_condition: int | None avg_market_price: int | None - _payload: Mapping[str, object] = field(default_factory=dict) enable_module_serialization(globals()) diff --git a/avito/core/exceptions.py b/avito/core/exceptions.py index 91cd01d..645562c 100644 --- a/avito/core/exceptions.py +++ b/avito/core/exceptions.py @@ -55,7 +55,9 @@ class AvitoError(Exception): def __post_init__(self) -> None: sanitized_payload = sanitize_metadata(self.payload) - sanitized_headers = sanitize_metadata(dict(self.headers)) if self.headers is not None else None + sanitized_headers = ( + sanitize_metadata(dict(self.headers)) if self.headers is not None else None + ) sanitized_metadata = sanitize_metadata(dict(self.metadata)) object.__setattr__(self, "payload", sanitized_payload) object.__setattr__(self, "headers", sanitized_headers) diff --git a/avito/core/transport.py b/avito/core/transport.py index bd8c39e..88bf0c9 100644 --- a/avito/core/transport.py +++ b/avito/core/transport.py @@ -355,7 +355,9 @@ def _decide_http_retry( ) return RetryDecision(False) - def _map_http_error(self, response: httpx.Response, *, operation: str | None = None) -> Exception: + def _map_http_error( + self, response: httpx.Response, *, operation: str | None = None + ) -> Exception: payload = self._safe_payload(response) message = self._extract_message(payload) or f"HTTP {response.status_code}" error_code = self._extract_error_code(payload) diff --git a/avito/cpa/domain.py b/avito/cpa/domain.py index 288eefd..f2c9475 100644 --- a/avito/cpa/domain.py +++ b/avito/cpa/domain.py @@ -142,7 +142,9 @@ class CallTrackingCall(DomainObject): user_id: int | str | None = None def get(self, *, call_id: int | None = None) -> CallTrackingCallResponse: - resolved_call_id = call_id or (int(self.resource_id) if self.resource_id is not None else None) + resolved_call_id = call_id or ( + int(self.resource_id) if self.resource_id is not None else None + ) if resolved_call_id is None: raise ValidationError("Для операции требуется `call_id`.") return CallTrackingClient(self.transport).get_call_by_id( diff --git a/avito/cpa/models.py b/avito/cpa/models.py index 619b967..d3b1df6 100644 --- a/avito/cpa/models.py +++ b/avito/cpa/models.py @@ -242,7 +242,7 @@ class CallTrackingCallsResult(SerializableModel): items: list[CallTrackingCallInfo] error: CpaErrorInfo | None = None - + @dataclass(slots=True, frozen=True) class CallTrackingGetCallByIdRequest: diff --git a/avito/jobs/__init__.py b/avito/jobs/__init__.py index fe07c5d..749cf18 100644 --- a/avito/jobs/__init__.py +++ b/avito/jobs/__init__.py @@ -3,9 +3,9 @@ from avito.jobs.domain import Application, DomainObject, JobDictionary, JobWebhook, Resume, Vacancy from avito.jobs.models import ( ApplicationActionRequest, - ApplicationIdsResult, ApplicationIdsQuery, ApplicationIdsRequest, + ApplicationIdsResult, ApplicationsResult, ApplicationStatesResult, ApplicationViewedItem, @@ -13,21 +13,21 @@ JobActionResult, JobDictionariesResult, JobDictionaryValuesResult, - JobWebhookUpdateRequest, JobWebhookInfo, JobWebhooksResult, + JobWebhookUpdateRequest, ResumeContactInfo, ResumeInfo, ResumeSearchQuery, ResumesResult, + VacanciesQuery, VacanciesResult, - VacancyInfo, VacancyArchiveRequest, VacancyAutoRenewalRequest, VacancyCreateRequest, VacancyIdsRequest, + VacancyInfo, VacancyProlongateRequest, - VacanciesQuery, VacancyStatusesResult, VacancyUpdateRequest, ) diff --git a/avito/jobs/client.py b/avito/jobs/client.py index d48babf..d220bd7 100644 --- a/avito/jobs/client.py +++ b/avito/jobs/client.py @@ -24,30 +24,30 @@ ) from avito.jobs.models import ( ApplicationActionRequest, - ApplicationIdsResult, ApplicationIdsQuery, ApplicationIdsRequest, + ApplicationIdsResult, ApplicationsResult, ApplicationStatesResult, ApplicationViewedRequest, JobActionResult, JobDictionariesResult, JobDictionaryValuesResult, - JobWebhookUpdateRequest, JobWebhookInfo, JobWebhooksResult, + JobWebhookUpdateRequest, ResumeContactInfo, ResumeInfo, ResumeSearchQuery, ResumesResult, + VacanciesQuery, VacanciesResult, - VacancyInfo, VacancyArchiveRequest, VacancyAutoRenewalRequest, VacancyCreateRequest, VacancyIdsRequest, + VacancyInfo, VacancyProlongateRequest, - VacanciesQuery, VacancyStatusesResult, VacancyUpdateRequest, ) diff --git a/avito/jobs/domain.py b/avito/jobs/domain.py index 5d347a2..b17e3cc 100644 --- a/avito/jobs/domain.py +++ b/avito/jobs/domain.py @@ -14,30 +14,30 @@ ) from avito.jobs.models import ( ApplicationActionRequest, - ApplicationIdsResult, ApplicationIdsQuery, ApplicationIdsRequest, + ApplicationIdsResult, ApplicationsResult, ApplicationStatesResult, ApplicationViewedRequest, JobActionResult, JobDictionariesResult, JobDictionaryValuesResult, - JobWebhookUpdateRequest, JobWebhookInfo, JobWebhooksResult, + JobWebhookUpdateRequest, ResumeContactInfo, ResumeInfo, ResumeSearchQuery, ResumesResult, + VacanciesQuery, VacanciesResult, - VacancyInfo, VacancyArchiveRequest, VacancyAutoRenewalRequest, VacancyCreateRequest, VacancyIdsRequest, + VacancyInfo, VacancyProlongateRequest, - VacanciesQuery, VacancyStatusesResult, VacancyUpdateRequest, ) diff --git a/avito/jobs/mappers.py b/avito/jobs/mappers.py index 22521f8..1666278 100644 --- a/avito/jobs/mappers.py +++ b/avito/jobs/mappers.py @@ -99,7 +99,6 @@ def map_job_action(payload: object) -> JobActionResult: id=identifier or (str(numeric_id) if numeric_id is not None else None), status=_str(source, "status", "state"), message=_str(source, "message"), - _payload=data, ) @@ -111,7 +110,6 @@ def map_application(payload: Payload) -> ApplicationInfo: state=_str(payload, "state", "status"), is_viewed=_bool(payload, "is_viewed", "isViewed"), applicant_name=_str(_mapping(payload, "applicant"), "name", "fullName"), - _payload=payload, ) @@ -124,7 +122,6 @@ def map_applications(payload: object) -> ApplicationsResult: map_application(item) for item in _list(data, "applies", "applications", "items", "result") ], - _payload=data, ) @@ -137,12 +134,10 @@ def map_application_ids(payload: object) -> ApplicationIdsResult: ApplicationIdItem( id=_str(item, "id"), updated_at=_str(item, "updatedAt", "updated_at"), - _payload=item, ) for item in _list(data, "items", "applies", "result") ], cursor=_str(_mapping(data, "meta"), "cursor") or _str(data, "cursor"), - _payload=data, ) @@ -155,11 +150,9 @@ def map_application_states(payload: object) -> ApplicationStatesResult: ApplicationState( slug=_str(item, "slug", "id"), description=_str(item, "description", "name"), - _payload=item, ) for item in _list(data, "states", "items", "result") ], - _payload=data, ) @@ -172,7 +165,6 @@ def map_resume(payload: Payload) -> ResumeInfo: location=_str(payload, "location") or _str(_mapping(payload, "address_details"), "location"), salary=_int(payload, "salary") or _int(salary_payload, "value", "from"), - _payload=payload, ) @@ -185,7 +177,6 @@ def map_resumes(payload: object) -> ResumesResult: items=[map_resume(item) for item in _list(data, "resumes", "items", "result")], cursor=_str(meta, "cursor"), total=_int(meta, "total"), - _payload=data, ) @@ -204,7 +195,6 @@ def map_resume_contacts(payload: object) -> ResumeContactInfo: name=_str(data, "name", "fullName"), phone=_str(data, "phone", "phoneNumber"), email=_str(data, "email"), - _payload=data, ) @@ -220,7 +210,6 @@ def map_vacancy(payload: Payload) -> VacancyInfo: title=_str(payload, "title", "name"), status=_str(payload, "status", "state"), url=_str(payload, "url"), - _payload=payload, ) @@ -242,7 +231,6 @@ def map_vacancies(payload: object) -> VacanciesResult: return VacanciesResult( items=[map_vacancy(item) for item in items], total=_int(meta, "total") or _int(data, "total"), - _payload=data, ) @@ -261,11 +249,9 @@ def map_vacancy_statuses(payload: object) -> VacancyStatusesResult: ), uuid=_str(item, "uuid", "vacancy_uuid"), status=_str(item, "status", "state"), - _payload=item, ) for item in _list(data, "items", "statuses", "vacancies", "result") ], - _payload=data, ) @@ -277,7 +263,6 @@ def map_job_webhook(payload: object) -> JobWebhookInfo: url=_str(data, "url"), is_active=_bool(data, "is_active", "isActive", "active"), version=_str(data, "version"), - _payload=data, ) @@ -286,14 +271,11 @@ def map_job_webhooks(payload: object) -> JobWebhooksResult: if isinstance(payload, list): items_payload = _expect_list(payload) - return JobWebhooksResult( - items=[map_job_webhook(item) for item in items_payload], _payload={} - ) + return JobWebhooksResult(items=[map_job_webhook(item) for item in items_payload]) data = _expect_mapping(payload) return JobWebhooksResult( items=[map_job_webhook(item) for item in _list(data, "items", "webhooks", "result")], - _payload=data, ) @@ -310,11 +292,9 @@ def map_job_dictionaries(payload: object) -> JobDictionariesResult: JobDictionaryInfo( id=_str(item, "id"), description=_str(item, "description"), - _payload=item, ) for item in items_payload ], - _payload={} if isinstance(payload, list) else _expect_mapping(payload), ) @@ -332,9 +312,7 @@ def map_job_dictionary_values(payload: object) -> JobDictionaryValuesResult: id=_int(item, "id") if _int(item, "id") is not None else _str(item, "id"), name=_str(item, "name", "description"), deprecated=_bool(item, "deprecated"), - _payload=item, ) for item in items_payload ], - _payload={} if isinstance(payload, list) else _expect_mapping(payload), ) diff --git a/avito/jobs/models.py b/avito/jobs/models.py index b85b0e6..b37d5e9 100644 --- a/avito/jobs/models.py +++ b/avito/jobs/models.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass from avito.core.serialization import enable_module_serialization @@ -189,7 +188,6 @@ class JobActionResult: id: str | None = None status: str | None = None message: str | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -202,7 +200,6 @@ class ApplicationInfo: state: str | None is_viewed: bool | None applicant_name: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -210,7 +207,6 @@ class ApplicationsResult: """Список откликов.""" items: list[ApplicationInfo] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -219,7 +215,6 @@ class ApplicationIdItem: id: str | None updated_at: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -228,7 +223,6 @@ class ApplicationIdsResult: items: list[ApplicationIdItem] cursor: str | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -237,7 +231,6 @@ class ApplicationState: slug: str | None description: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -245,7 +238,6 @@ class ApplicationStatesResult: """Список возможных статусов откликов.""" items: list[ApplicationState] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -257,7 +249,6 @@ class ResumeInfo: candidate_name: str | None location: str | None salary: int | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -267,7 +258,6 @@ class ResumesResult: items: list[ResumeInfo] cursor: str | None = None total: int | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -277,7 +267,6 @@ class ResumeContactInfo: name: str | None phone: str | None email: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -289,7 +278,6 @@ class VacancyInfo: title: str | None status: str | None url: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -298,7 +286,6 @@ class VacanciesResult: items: list[VacancyInfo] total: int | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -308,7 +295,6 @@ class VacancyStatusInfo: id: str | None uuid: str | None status: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -316,7 +302,6 @@ class VacancyStatusesResult: """Список статусов вакансий.""" items: list[VacancyStatusInfo] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -326,7 +311,6 @@ class JobWebhookInfo: url: str | None is_active: bool | None version: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -334,7 +318,6 @@ class JobWebhooksResult: """Список webhook-подписок.""" items: list[JobWebhookInfo] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -343,7 +326,6 @@ class JobDictionaryInfo: id: str | None description: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -351,7 +333,6 @@ class JobDictionariesResult: """Список доступных словарей.""" items: list[JobDictionaryInfo] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -361,7 +342,6 @@ class JobDictionaryValue: id: int | str | None name: str | None deprecated: bool | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -369,7 +349,6 @@ class JobDictionaryValuesResult: """Список значений словаря.""" items: list[JobDictionaryValue] - _payload: Mapping[str, object] = field(default_factory=dict) enable_module_serialization(globals()) diff --git a/avito/messenger/mappers.py b/avito/messenger/mappers.py index a5bd90e..d95c421 100644 --- a/avito/messenger/mappers.py +++ b/avito/messenger/mappers.py @@ -91,7 +91,6 @@ def map_chat(payload: object) -> ChatInfo: title=_str(data, "title", "name"), unread_count=_int(data, "unread_count", "unreadCount"), last_message_text=_str(last_message_data, "text", "message"), - _payload=data, ) @@ -102,7 +101,6 @@ def map_chats(payload: object) -> ChatsResult: return ChatsResult( items=[map_chat(item) for item in _list(data, "chats", "items", "result")], total=_int(data, "total", "count"), - _payload=data, ) @@ -118,7 +116,6 @@ def map_message(payload: object) -> MessageInfo: created_at=_str(data, "created_at", "createdAt"), direction=_str(data, "direction"), type=_str(data, "type"), - _payload=data, ) @@ -129,7 +126,6 @@ def map_messages(payload: object) -> MessagesResult: return MessagesResult( items=[map_message(item) for item in _list(data, "messages", "items", "result")], total=_int(data, "total", "count"), - _payload=data, ) @@ -141,7 +137,6 @@ def map_message_action(payload: object) -> MessageActionResult: success=bool(data.get("success", True)), message_id=_str(data, "message_id", "messageId", "id"), status=_str(data, "status", "message"), - _payload=data, ) @@ -156,11 +151,9 @@ def map_voice_files(payload: object) -> VoiceFilesResult: url=_str(item, "url"), duration=_int(item, "duration"), transcript=_str(item, "transcript", "text"), - _payload=item, ) for item in _list(data, "voice_files", "items", "result") ], - _payload=data, ) @@ -173,11 +166,9 @@ def map_upload_images(payload: object) -> UploadImagesResult: UploadImageResult( image_id=_str(item, "image_id", "imageId", "id"), url=_str(item, "url"), - _payload=item, ) for item in _list(data, "images", "items", "result") ], - _payload=data, ) @@ -191,11 +182,9 @@ def map_subscriptions(payload: object) -> SubscriptionsResult: url=_str(item, "url"), version=_str(item, "version"), status=_str(item, "status"), - _payload=item, ) for item in _list(data, "subscriptions", "items", "result") ], - _payload=data, ) @@ -206,7 +195,6 @@ def map_webhook_action(payload: object) -> WebhookActionResult: return WebhookActionResult( success=bool(data.get("success", True)), status=_str(data, "status", "message"), - _payload=data, ) @@ -220,11 +208,9 @@ def map_available_special_offers(payload: object) -> SpecialOfferAvailableResult item_id=_int(item, "item_id", "itemId", "id"), title=_str(item, "title"), is_available=_bool(item, "is_available", "isAvailable", "available"), - _payload=item, ) for item in _list(data, "items", "result") ], - _payload=data, ) @@ -235,7 +221,6 @@ def map_multi_create_result(payload: object) -> MultiCreateSpecialOfferResult: return MultiCreateSpecialOfferResult( campaign_id=_str(data, "campaign_id", "campaignId", "id"), status=_str(data, "status"), - _payload=data, ) @@ -248,7 +233,6 @@ def map_special_offer_stats(payload: object) -> SpecialOfferStatsResult: sent_count=_int(data, "sent_count", "sentCount"), delivered_count=_int(data, "delivered_count", "deliveredCount"), read_count=_int(data, "read_count", "readCount"), - _payload=data, ) @@ -260,7 +244,6 @@ def map_tariff_info(payload: object) -> TariffInfo: price=_float(data, "price", "amount"), currency=_str(data, "currency"), daily_limit=_int(data, "daily_limit", "dailyLimit", "limit"), - _payload=data, ) diff --git a/avito/messenger/models.py b/avito/messenger/models.py index 8bb9605..df059cc 100644 --- a/avito/messenger/models.py +++ b/avito/messenger/models.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import BinaryIO from avito.core.serialization import enable_module_serialization @@ -18,7 +17,6 @@ class ChatInfo: title: str | None unread_count: int | None last_message_text: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -27,7 +25,6 @@ class ChatsResult: items: list[ChatInfo] total: int | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -75,7 +72,6 @@ class MessageInfo: created_at: str | None direction: str | None type: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -84,7 +80,6 @@ class MessagesResult: items: list[MessageInfo] total: int | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -94,7 +89,6 @@ class MessageActionResult: success: bool message_id: str | None = None status: str | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -105,7 +99,6 @@ class VoiceFile: url: str | None duration: int | None transcript: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -113,7 +106,6 @@ class VoiceFilesResult: """Список голосовых сообщений.""" items: list[VoiceFile] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -122,7 +114,6 @@ class UploadImageResult: image_id: str | None url: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -130,7 +121,6 @@ class UploadImagesResult: """Список загруженных изображений.""" items: list[UploadImageResult] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -153,8 +143,7 @@ def to_files(self) -> dict[str, object]: """Сериализует multipart-структуру для transport.""" return { - file.field_name: (file.filename, file.content, file.content_type) - for file in self.files + file.field_name: (file.filename, file.content, file.content_type) for file in self.files } @@ -165,7 +154,6 @@ class SubscriptionInfo: url: str | None version: str | None status: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -173,7 +161,6 @@ class SubscriptionsResult: """Список webhook-подписок.""" items: list[SubscriptionInfo] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -211,7 +198,6 @@ class WebhookActionResult: success: bool status: str | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -245,7 +231,6 @@ class SpecialOfferAvailableItem: item_id: int | None title: str | None is_available: bool | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -253,7 +238,6 @@ class SpecialOfferAvailableResult: """Результат получения доступных объявлений.""" items: list[SpecialOfferAvailableItem] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -284,7 +268,6 @@ class MultiCreateSpecialOfferResult: campaign_id: str | None status: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -319,7 +302,6 @@ class SpecialOfferStatsResult: sent_count: int | None delivered_count: int | None read_count: int | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -329,7 +311,6 @@ class TariffInfo: price: float | None currency: str | None daily_limit: int | None - _payload: Mapping[str, object] = field(default_factory=dict) __all__ = ( diff --git a/avito/orders/__init__.py b/avito/orders/__init__.py index 23225b6..4f63411 100644 --- a/avito/orders/__init__.py +++ b/avito/orders/__init__.py @@ -10,14 +10,39 @@ Stock, ) from avito.orders.models import ( + AddSortingCentersRequest, + AddTariffV2Request, + AddTerminalsRequest, + CancelParcelRequest, + CancelSandboxParcelRequest, + ChangeParcelApplication, + ChangeParcelOptions, + ChangeParcelRequest, CourierRangesResult, + CustomAreaScheduleEntry, + CustomAreaScheduleRequest, + DeliveryAddress, DeliveryAnnouncementRequest, + DeliveryDateInterval, + DeliveryDirection, + DeliveryDirectionZone, DeliveryEntityResult, DeliveryParcelIdsRequest, DeliveryParcelRequest, DeliveryParcelResultRequest, + DeliveryRestriction, DeliverySortingCentersResult, + DeliveryTariffItem, + DeliveryTariffValue, + DeliveryTariffZone, DeliveryTaskInfo, + DeliveryTerms, + DeliveryTermsZone, + DeliveryTrackingOptions, + DeliveryTrackingRequest, + GetChangeParcelInfoRequest, + GetRegisteredParcelIdRequest, + GetSandboxParcelInfoRequest, LabelPdfResult, LabelTaskResult, OrderAcceptReturnRequest, @@ -26,22 +51,46 @@ OrderCncDetailsRequest, OrderConfirmationCodeRequest, OrderCourierRangeRequest, + OrderDeliveryProperties, OrderLabelsRequest, OrderMarkingsRequest, - OrderTrackingNumberRequest, OrdersResult, + OrderTrackingNumberRequest, + ProhibitOrderAcceptanceRequest, + RealAddress, + SandboxAnnouncementDelivery, + SandboxAnnouncementPackage, + SandboxAnnouncementParticipant, SandboxArea, SandboxAreasRequest, + SandboxCancelAnnouncementOptions, + SandboxCancelAnnouncementRequest, + SandboxConfirmationCodeRequest, + SandboxCreateAnnouncementOptions, + SandboxCreateAnnouncementRequest, + SandboxDeliveryPoint, + SandboxGetAnnouncementEventRequest, + SetOrderPropertiesRequest, + SetOrderRealAddressRequest, StockInfoRequest, StockInfoResult, StockUpdateEntry, StockUpdateRequest, StockUpdateResult, + TaggedSortingCenter, + TaggedSortingCentersRequest, + TerminalUpload, + UpdateTermsRequest, + WeeklySchedule, ) __all__ = ( "CourierRangesResult", + "CustomAreaScheduleEntry", + "CustomAreaScheduleRequest", + "DeliveryAddress", "DeliveryAnnouncementRequest", + "DeliveryDateInterval", "DeliveryEntityResult", "DeliveryOrder", "DeliveryParcelIdsRequest", @@ -50,10 +99,13 @@ "DeliverySortingCentersResult", "DeliveryTask", "DeliveryTaskInfo", + "DeliveryDirection", + "DeliveryDirectionZone", "DomainObject", "LabelPdfResult", "LabelTaskResult", "Order", + "OrderDeliveryProperties", "OrderAcceptReturnRequest", "OrderActionResult", "OrderApplyTransitionRequest", @@ -65,13 +117,51 @@ "OrderMarkingsRequest", "OrderTrackingNumberRequest", "OrdersResult", + "ProhibitOrderAcceptanceRequest", + "RealAddress", "SandboxArea", "SandboxAreasRequest", "SandboxDelivery", + "SandboxConfirmationCodeRequest", "Stock", "StockInfoRequest", "StockInfoResult", "StockUpdateEntry", "StockUpdateRequest", "StockUpdateResult", + "SetOrderPropertiesRequest", + "SetOrderRealAddressRequest", + "WeeklySchedule", + "DeliveryRestriction", + "AddSortingCentersRequest", + "TaggedSortingCenter", + "TaggedSortingCentersRequest", + "TerminalUpload", + "AddTerminalsRequest", + "DeliveryTermsZone", + "UpdateTermsRequest", + "DeliveryTrackingOptions", + "DeliveryTrackingRequest", + "DeliveryTerms", + "CancelParcelRequest", + "AddTariffV2Request", + "DeliveryTariffValue", + "DeliveryTariffItem", + "DeliveryTariffZone", + "SandboxCancelAnnouncementOptions", + "SandboxCancelAnnouncementRequest", + "CancelSandboxParcelRequest", + "ChangeParcelApplication", + "ChangeParcelOptions", + "ChangeParcelRequest", + "SandboxCreateAnnouncementOptions", + "SandboxDeliveryPoint", + "SandboxAnnouncementDelivery", + "SandboxAnnouncementParticipant", + "SandboxAnnouncementPackage", + "SandboxCreateAnnouncementRequest", + "SandboxGetAnnouncementEventRequest", + "GetChangeParcelInfoRequest", + "GetSandboxParcelInfoRequest", + "GetRegisteredParcelIdRequest", ) diff --git a/avito/orders/client.py b/avito/orders/client.py index 2437fd4..71dc9cb 100644 --- a/avito/orders/client.py +++ b/avito/orders/client.py @@ -17,7 +17,14 @@ map_stock_update, ) from avito.orders.models import ( + AddSortingCentersRequest, + AddTariffV2Request, + AddTerminalsRequest, + CancelParcelRequest, + CancelSandboxParcelRequest, + ChangeParcelRequest, CourierRangesResult, + CustomAreaScheduleRequest, DeliveryAnnouncementRequest, DeliveryEntityResult, DeliveryParcelIdsRequest, @@ -25,6 +32,10 @@ DeliveryParcelResultRequest, DeliverySortingCentersResult, DeliveryTaskInfo, + DeliveryTrackingRequest, + GetChangeParcelInfoRequest, + GetRegisteredParcelIdRequest, + GetSandboxParcelInfoRequest, LabelPdfResult, LabelTaskResult, OrderAcceptReturnRequest, @@ -35,14 +46,22 @@ OrderCourierRangeRequest, OrderLabelsRequest, OrderMarkingsRequest, - OrderTrackingNumberRequest, - OrdersRequest, OrdersResult, + OrderTrackingNumberRequest, + ProhibitOrderAcceptanceRequest, SandboxAreasRequest, + SandboxCancelAnnouncementRequest, + SandboxConfirmationCodeRequest, + SandboxCreateAnnouncementRequest, + SandboxGetAnnouncementEventRequest, + SetOrderPropertiesRequest, + SetOrderRealAddressRequest, StockInfoRequest, StockInfoResult, StockUpdateRequest, StockUpdateResult, + TaggedSortingCentersRequest, + UpdateTermsRequest, ) @@ -228,41 +247,47 @@ def track_announcement(self, request: DeliveryAnnouncementRequest) -> DeliveryEn "/delivery-sandbox/announcements/track", "orders.sandbox.track_announcement", request ) - def update_custom_area_schedule(self, request: OrdersRequest) -> DeliveryEntityResult: + def update_custom_area_schedule( + self, request: CustomAreaScheduleRequest + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/areas/custom-schedule", "orders.sandbox.update_custom_area_schedule", request, ) - def cancel_parcel(self, request: OrdersRequest) -> DeliveryEntityResult: + def cancel_parcel(self, request: CancelParcelRequest) -> DeliveryEntityResult: return self._post("/delivery-sandbox/cancelParcel", "orders.sandbox.cancel_parcel", request) - def check_confirmation_code(self, request: OrdersRequest) -> DeliveryEntityResult: + def check_confirmation_code( + self, request: SandboxConfirmationCodeRequest + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/order/checkConfirmationCode", "orders.sandbox.check_confirmation_code", request, ) - def set_order_properties(self, request: OrdersRequest) -> DeliveryEntityResult: + def set_order_properties(self, request: SetOrderPropertiesRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/order/properties", "orders.sandbox.set_order_properties", request, ) - def set_order_real_address(self, request: OrdersRequest) -> DeliveryEntityResult: + def set_order_real_address(self, request: SetOrderRealAddressRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/order/realAddress", "orders.sandbox.set_order_real_address", request, ) - def tracking(self, request: OrdersRequest) -> DeliveryEntityResult: + def tracking(self, request: DeliveryTrackingRequest) -> DeliveryEntityResult: return self._post("/delivery-sandbox/order/tracking", "orders.sandbox.tracking", request) - def prohibit_order_acceptance(self, request: OrdersRequest) -> DeliveryEntityResult: + def prohibit_order_acceptance( + self, request: ProhibitOrderAcceptanceRequest + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/prohibitOrderAcceptance", "orders.sandbox.prohibit_order_acceptance", @@ -277,7 +302,7 @@ def list_sorting_center(self) -> DeliverySortingCentersResult: ) return map_sorting_centers(payload) - def add_sorting_center(self, request: OrdersRequest) -> DeliveryEntityResult: + def add_sorting_center(self, request: AddSortingCentersRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/tariffs/sorting-center", "orders.sandbox.add_sorting_center", @@ -292,7 +317,7 @@ def add_areas(self, *, tariff_id: str, request: SandboxAreasRequest) -> Delivery ) def add_tags_to_sorting_center( - self, *, tariff_id: str, request: OrdersRequest + self, *, tariff_id: str, request: TaggedSortingCentersRequest ) -> DeliveryEntityResult: return self._post( f"/delivery-sandbox/tariffs/{tariff_id}/tagged-sorting-centers", @@ -300,69 +325,81 @@ def add_tags_to_sorting_center( request, ) - def add_terminals(self, *, tariff_id: str, request: OrdersRequest) -> DeliveryEntityResult: + def add_terminals( + self, *, tariff_id: str, request: AddTerminalsRequest + ) -> DeliveryEntityResult: return self._post( f"/delivery-sandbox/tariffs/{tariff_id}/terminals", "orders.sandbox.add_terminals", request, ) - def update_terms(self, *, tariff_id: str, request: OrdersRequest) -> DeliveryEntityResult: + def update_terms(self, *, tariff_id: str, request: UpdateTermsRequest) -> DeliveryEntityResult: return self._post( f"/delivery-sandbox/tariffs/{tariff_id}/terms", "orders.sandbox.update_terms", request, ) - def add_tariff_v2(self, request: OrdersRequest) -> DeliveryEntityResult: + def add_tariff_v2(self, request: AddTariffV2Request) -> DeliveryEntityResult: return self._post("/delivery-sandbox/tariffsV2", "orders.sandbox.add_tariff_v2", request) - def v1_cancel_announcement(self, request: OrdersRequest) -> DeliveryEntityResult: + def v1_cancel_announcement( + self, request: SandboxCancelAnnouncementRequest + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/cancelAnnouncement", "orders.sandbox.v1_cancel_announcement", request, ) - def v1_cancel_parcel(self, request: OrdersRequest) -> DeliveryEntityResult: + def v1_cancel_parcel(self, request: CancelSandboxParcelRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/cancelParcel", "orders.sandbox.v1_cancel_parcel", request ) - def v1_change_parcel(self, request: OrdersRequest) -> DeliveryEntityResult: + def v1_change_parcel(self, request: ChangeParcelRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/changeParcel", "orders.sandbox.v1_change_parcel", request ) - def v1_create_announcement(self, request: OrdersRequest) -> DeliveryEntityResult: + def v1_create_announcement( + self, request: SandboxCreateAnnouncementRequest + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/createAnnouncement", "orders.sandbox.v1_create_announcement", request, ) - def v1_get_announcement_event(self, request: OrdersRequest) -> DeliveryEntityResult: + def v1_get_announcement_event( + self, request: SandboxGetAnnouncementEventRequest + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/getAnnouncementEvent", "orders.sandbox.v1_get_announcement_event", request, ) - def v1_get_change_parcel_info(self, request: OrdersRequest) -> DeliveryEntityResult: + def v1_get_change_parcel_info( + self, request: GetChangeParcelInfoRequest + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/getChangeParcelInfo", "orders.sandbox.v1_get_change_parcel_info", request, ) - def v1_get_parcel_info(self, request: OrdersRequest) -> DeliveryEntityResult: + def v1_get_parcel_info(self, request: GetSandboxParcelInfoRequest) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/getParcelInfo", "orders.sandbox.v1_get_parcel_info", request, ) - def v1_get_registered_parcel_id(self, request: OrdersRequest) -> DeliveryEntityResult: + def v1_get_registered_parcel_id( + self, request: GetRegisteredParcelIdRequest + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/getRegisteredParcelID", "orders.sandbox.v1_get_registered_parcel_id", @@ -378,9 +415,28 @@ def _post( self, path: str, operation: str, - request: OrdersRequest + request: CustomAreaScheduleRequest + | CancelParcelRequest + | SandboxConfirmationCodeRequest + | SetOrderPropertiesRequest + | SetOrderRealAddressRequest + | DeliveryTrackingRequest + | ProhibitOrderAcceptanceRequest + | AddSortingCentersRequest | DeliveryAnnouncementRequest | SandboxAreasRequest + | TaggedSortingCentersRequest + | AddTerminalsRequest + | UpdateTermsRequest + | AddTariffV2Request + | SandboxCancelAnnouncementRequest + | CancelSandboxParcelRequest + | ChangeParcelRequest + | SandboxCreateAnnouncementRequest + | SandboxGetAnnouncementEventRequest + | GetChangeParcelInfoRequest + | GetSandboxParcelInfoRequest + | GetRegisteredParcelIdRequest | DeliveryParcelRequest, ) -> DeliveryEntityResult: payload = self.transport.request_json( diff --git a/avito/orders/domain.py b/avito/orders/domain.py index ef8a099..78506c0 100644 --- a/avito/orders/domain.py +++ b/avito/orders/domain.py @@ -14,7 +14,14 @@ StockManagementClient, ) from avito.orders.models import ( + AddSortingCentersRequest, + AddTariffV2Request, + AddTerminalsRequest, + CancelParcelRequest, + CancelSandboxParcelRequest, + ChangeParcelRequest, CourierRangesResult, + CustomAreaScheduleRequest, DeliveryAnnouncementRequest, DeliveryEntityResult, DeliveryParcelIdsRequest, @@ -22,6 +29,10 @@ DeliveryParcelResultRequest, DeliverySortingCentersResult, DeliveryTaskInfo, + DeliveryTrackingRequest, + GetChangeParcelInfoRequest, + GetRegisteredParcelIdRequest, + GetSandboxParcelInfoRequest, LabelPdfResult, LabelTaskResult, OrderAcceptReturnRequest, @@ -32,14 +43,22 @@ OrderCourierRangeRequest, OrderLabelsRequest, OrderMarkingsRequest, - OrderTrackingNumberRequest, - OrdersRequest, OrdersResult, + OrderTrackingNumberRequest, + ProhibitOrderAcceptanceRequest, SandboxAreasRequest, + SandboxCancelAnnouncementRequest, + SandboxConfirmationCodeRequest, + SandboxCreateAnnouncementRequest, + SandboxGetAnnouncementEventRequest, + SetOrderPropertiesRequest, + SetOrderRealAddressRequest, StockInfoRequest, StockInfoResult, StockUpdateRequest, StockUpdateResult, + TaggedSortingCentersRequest, + UpdateTermsRequest, ) @@ -69,7 +88,9 @@ def accept_return_order(self, *, request: OrderAcceptReturnRequest) -> OrderActi def apply(self, *, request: OrderApplyTransitionRequest) -> OrderActionResult: return OrdersClient(self.transport).apply_transition(request) - def check_confirmation_code(self, *, request: OrderConfirmationCodeRequest) -> OrderActionResult: + def check_confirmation_code( + self, *, request: OrderConfirmationCodeRequest + ) -> OrderActionResult: return OrdersClient(self.transport).check_confirmation_code(request) def set_cnc_details(self, *, request: OrderCncDetailsRequest) -> OrderActionResult: @@ -127,7 +148,9 @@ def create(self, *, request: DeliveryParcelRequest) -> DeliveryEntityResult: def update_change_parcels(self, *, request: DeliveryParcelIdsRequest) -> DeliveryEntityResult: return DeliveryClient(self.transport).update_change_parcels(request) - def create_change_parcel_result(self, *, request: DeliveryParcelResultRequest) -> DeliveryEntityResult: + def create_change_parcel_result( + self, *, request: DeliveryParcelResultRequest + ) -> DeliveryEntityResult: return DeliveryClient(self.transport).change_parcel_result(request) @@ -144,40 +167,46 @@ def create_announcement(self, *, request: DeliveryAnnouncementRequest) -> Delive def track_announcement(self, *, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).track_announcement(request) - def update_custom_area_schedule(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def update_custom_area_schedule( + self, *, request: CustomAreaScheduleRequest + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).update_custom_area_schedule(request) - def cancel_parcel(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def cancel_parcel(self, *, request: CancelParcelRequest) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).cancel_parcel(request) - def check_confirmation_code(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def check_confirmation_code( + self, *, request: SandboxConfirmationCodeRequest + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).check_confirmation_code(request) - def set_order_properties(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def set_order_properties(self, *, request: SetOrderPropertiesRequest) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).set_order_properties(request) - def set_order_real_address(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def set_order_real_address( + self, *, request: SetOrderRealAddressRequest + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).set_order_real_address(request) - def tracking(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def tracking(self, *, request: DeliveryTrackingRequest) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).tracking(request) - def prohibit_order_acceptance(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def prohibit_order_acceptance( + self, *, request: ProhibitOrderAcceptanceRequest + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).prohibit_order_acceptance(request) def list_sorting_center(self) -> DeliverySortingCentersResult: return SandboxDeliveryClient(self.transport).list_sorting_center() - def add_sorting_center(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def add_sorting_center(self, *, request: AddSortingCentersRequest) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).add_sorting_center(request) def add_areas(self, *, tariff_id: str, request: SandboxAreasRequest) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).add_areas( - tariff_id=tariff_id, request=request - ) + return SandboxDeliveryClient(self.transport).add_areas(tariff_id=tariff_id, request=request) def add_tags_to_sorting_center( - self, *, tariff_id: str, request: OrdersRequest + self, *, tariff_id: str, request: TaggedSortingCentersRequest ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).add_tags_to_sorting_center( tariff_id=tariff_id, @@ -185,52 +214,54 @@ def add_tags_to_sorting_center( ) def add_terminals( - self, *, tariff_id: str, request: OrdersRequest + self, *, tariff_id: str, request: AddTerminalsRequest ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).add_terminals( tariff_id=tariff_id, request=request ) - def update_terms( - self, *, tariff_id: str, request: OrdersRequest - ) -> DeliveryEntityResult: + def update_terms(self, *, tariff_id: str, request: UpdateTermsRequest) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).update_terms( tariff_id=tariff_id, request=request ) - def add_tariff_v2(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def add_tariff_v2(self, *, request: AddTariffV2Request) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).add_tariff_v2(request) def create_parcel(self, *, request: DeliveryParcelRequest) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).create_parcel_v2(request) - def cancel_announcement_v1(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def cancel_announcement_v1( + self, *, request: SandboxCancelAnnouncementRequest + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).v1_cancel_announcement(request) - def cancel_parcel_v1(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def cancel_parcel_v1(self, *, request: CancelSandboxParcelRequest) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).v1_cancel_parcel(request) - def change_parcel_v1(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def change_parcel_v1(self, *, request: ChangeParcelRequest) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).v1_change_parcel(request) - def create_announcement_v1(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def create_announcement_v1( + self, *, request: SandboxCreateAnnouncementRequest + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).v1_create_announcement(request) def get_announcement_event_v1( - self, *, request: OrdersRequest + self, *, request: SandboxGetAnnouncementEventRequest ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).v1_get_announcement_event(request) def get_change_parcel_info_v1( - self, *, request: OrdersRequest + self, *, request: GetChangeParcelInfoRequest ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).v1_get_change_parcel_info(request) - def get_parcel_info_v1(self, *, request: OrdersRequest) -> DeliveryEntityResult: + def get_parcel_info_v1(self, *, request: GetSandboxParcelInfoRequest) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).v1_get_parcel_info(request) def get_registered_parcel_id_v1( - self, *, request: OrdersRequest + self, *, request: GetRegisteredParcelIdRequest ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).v1_get_registered_parcel_id(request) diff --git a/avito/orders/mappers.py b/avito/orders/mappers.py index c97443c..e2bfed7 100644 --- a/avito/orders/mappers.py +++ b/avito/orders/mappers.py @@ -86,12 +86,10 @@ def map_orders(payload: object) -> OrdersResult: created_at=_str(item, "created", "created_at", "createdAt"), buyer_name=_str(_mapping(item, "buyerInfo"), "fullName"), total_price=_int(item, "totalPrice", "price"), - _payload=item, ) for item in _list(data, "orders", "items", "result") ], total=_int(data, "total", "count"), - _payload=data, ) @@ -106,7 +104,6 @@ def map_order_action(payload: object) -> OrderActionResult: order_id=_str(source, "orderId", "order_id", "id"), status=_str(source, "status"), message=_str(source, "message"), - _payload=data, ) @@ -123,12 +120,10 @@ def map_courier_ranges(payload: object) -> CourierRangesResult: date=_str(item, "date"), start_at=_str(item, "startAt", "startDate"), end_at=_str(item, "endAt", "endDate"), - _payload=item, ) for item in _list(source, "timeIntervals", "intervals", "items", "result") ], address=_str(source, "address"), - _payload=data, ) @@ -143,7 +138,6 @@ def map_label_task(payload: object) -> LabelTaskResult: return LabelTaskResult( task_id=task_id or (str(task_int) if task_int is not None else None), status=_str(source, "status"), - _payload=data, ) @@ -162,7 +156,6 @@ def map_delivery_entity(payload: object) -> DeliveryEntityResult: parcel_id=_str(source, "parcelId", "parcelID"), status=_str(source, "status"), message=_str(_mapping(data, "error"), "message") or _str(source, "message"), - _payload=data, ) @@ -178,11 +171,9 @@ def map_sorting_centers(payload: object) -> DeliverySortingCentersResult: sorting_center_id=_str(item, "id", "sortingCenterId", "sorting_center_id"), name=_str(item, "name"), city=_str(item, "city"), - _payload=item, ) for item in _list(source, "sortingCenters", "items", "result") ], - _payload=data, ) @@ -198,7 +189,6 @@ def map_delivery_task(payload: object) -> DeliveryTaskInfo: task_id=task_id or (str(task_int) if task_int is not None else None), status=_str(source, "status"), error=_str(_mapping(data, "error"), "message") or _str(source, "error"), - _payload=data, ) @@ -214,11 +204,9 @@ def map_stock_info(payload: object) -> StockInfoResult: is_multiple=_bool(item, "is_multiple", "isMultiple"), is_unlimited=_bool(item, "is_unlimited", "isUnlimited"), is_out_of_stock=_bool(item, "is_out_of_stock", "isOutOfStock"), - _payload=item, ) for item in _list(data, "stocks", "items", "result") ], - _payload=data, ) @@ -233,11 +221,9 @@ def map_stock_update(payload: object) -> StockUpdateResult: external_id=_str(item, "external_id", "externalId"), success=bool(item.get("success", True)), errors=_extract_errors(item), - _payload=item, ) for item in _list(data, "stocks", "items", "result") ], - _payload=data, ) diff --git a/avito/orders/models.py b/avito/orders/models.py index a086412..2807a64 100644 --- a/avito/orders/models.py +++ b/avito/orders/models.py @@ -3,8 +3,7 @@ from __future__ import annotations from base64 import b64encode -from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any from avito.core import BinaryResponse @@ -12,13 +11,44 @@ @dataclass(slots=True, frozen=True) -class OrdersRequest: - """Временный generic request для ещё не мигрированных endpoints orders.""" +class DeliveryDateInterval: + """Интервалы доставки/забора для конкретной даты.""" - payload: Mapping[str, object] + date: str + intervals: list[str] def to_payload(self) -> dict[str, object]: - return dict(self.payload) + return {"date": self.date, "intervals": list(self.intervals)} + + +@dataclass(slots=True, frozen=True) +class CustomAreaScheduleEntry: + """Кастомное расписание для списка областей доставки.""" + + provider_area_numbers: list[str] + services: list[str] + custom_schedule: list[DeliveryDateInterval] + use_all_areas: bool | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = { + "providerAreaNumber": list(self.provider_area_numbers), + "services": list(self.services), + "customSchedule": [entry.to_payload() for entry in self.custom_schedule], + } + if self.use_all_areas is not None: + payload["useAllAreas"] = self.use_all_areas + return payload + + +@dataclass(slots=True, frozen=True) +class CustomAreaScheduleRequest: + """Запрос установки кастомного расписания областей.""" + + items: list[CustomAreaScheduleEntry] + + def to_payload(self) -> list[dict[str, object]]: + return [item.to_payload() for item in self.items] @dataclass(slots=True, frozen=True) @@ -140,6 +170,172 @@ def to_payload(self) -> dict[str, object]: return {"parcelId": self.parcel_id, "result": self.result} +@dataclass(slots=True, frozen=True) +class CancelParcelRequest: + """Запрос отмены sandbox-посылки.""" + + parcel_id: str + actor: str + + def to_payload(self) -> dict[str, object]: + return {"parcelID": self.parcel_id, "actor": self.actor} + + +@dataclass(slots=True, frozen=True) +class SandboxConfirmationCodeRequest: + """Запрос проверки кода подтверждения sandbox-заказа.""" + + parcel_id: str + confirm_code: str + + def to_payload(self) -> dict[str, object]: + return {"parcelID": self.parcel_id, "confirmCode": self.confirm_code} + + +@dataclass(slots=True, frozen=True) +class DeliveryTerms: + """Параметры условий доставки заказа.""" + + cost: int | None = None + direct_control_date: str | None = None + receiver_terminal_code: str | None = None + return_control_date: str | None = None + sender_receive_terminal_code: str | None = None + tough_wrap: bool | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {} + if self.cost is not None: + payload["cost"] = self.cost + if self.direct_control_date is not None: + payload["directControlDate"] = self.direct_control_date + if self.receiver_terminal_code is not None: + payload["receiverTerminalCode"] = self.receiver_terminal_code + if self.return_control_date is not None: + payload["returnControlDate"] = self.return_control_date + if self.sender_receive_terminal_code is not None: + payload["senderReceiveTerminalCode"] = self.sender_receive_terminal_code + if self.tough_wrap is not None: + payload["toughWrap"] = self.tough_wrap + return payload + + +@dataclass(slots=True, frozen=True) +class OrderDeliveryProperties: + """Набор параметров доставки sandbox-заказа.""" + + delivery: DeliveryTerms | None = None + dimensions: list[int] | None = None + weight: int | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {} + if self.delivery is not None: + payload["delivery"] = self.delivery.to_payload() + if self.dimensions is not None: + payload["dimensions"] = list(self.dimensions) + if self.weight is not None: + payload["weight"] = self.weight + return payload + + +@dataclass(slots=True, frozen=True) +class SetOrderPropertiesRequest: + """Запрос установки параметров доставки sandbox-заказа.""" + + order_id: str + properties: OrderDeliveryProperties + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id, "properties": self.properties.to_payload()} + + +@dataclass(slots=True, frozen=True) +class RealAddress: + """Фактический адрес приема или возврата.""" + + address_type: str + terminal_number: str + + def to_payload(self) -> dict[str, object]: + return { + "addressType": self.address_type, + "terminalNumber": self.terminal_number, + } + + +@dataclass(slots=True, frozen=True) +class SetOrderRealAddressRequest: + """Запрос передачи фактического адреса sandbox-заказа.""" + + order_id: str + address: RealAddress + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id, "address": self.address.to_payload()} + + +@dataclass(slots=True, frozen=True) +class DeliveryTrackingOptions: + """Дополнительные поля tracking-события.""" + + barcode: str | None = None + return_barcode: str | None = None + return_dispatch_number: str | None = None + return_tracking_number: str | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {} + if self.barcode is not None: + payload["barcode"] = self.barcode + if self.return_barcode is not None: + payload["returnBarcode"] = self.return_barcode + if self.return_dispatch_number is not None: + payload["returnDispatchNumber"] = self.return_dispatch_number + if self.return_tracking_number is not None: + payload["returnTrackingNumber"] = self.return_tracking_number + return payload + + +@dataclass(slots=True, frozen=True) +class DeliveryTrackingRequest: + """Запрос передачи tracking-события sandbox-заказа.""" + + order_id: str + avito_status: str + avito_event_type: str + provider_event_code: str + date: str + location: str + comment: str | None = None + options: DeliveryTrackingOptions | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = { + "orderId": self.order_id, + "avitoStatus": self.avito_status, + "avitoEventType": self.avito_event_type, + "providerEventCode": self.provider_event_code, + "date": self.date, + "location": self.location, + } + if self.comment is not None: + payload["comment"] = self.comment + if self.options is not None: + payload["options"] = self.options.to_payload() + return payload + + +@dataclass(slots=True, frozen=True) +class ProhibitOrderAcceptanceRequest: + """Запрос запрета приема sandbox-посылки.""" + + order_id: str + + def to_payload(self) -> dict[str, object]: + return {"orderId": self.order_id} + + @dataclass(slots=True, frozen=True) class DeliveryParcelIdsRequest: """Запрос пакетной операции по посылкам.""" @@ -160,6 +356,625 @@ def to_payload(self) -> dict[str, object]: return {"city": self.city} +@dataclass(slots=True, frozen=True) +class DeliveryAddress: + """Адрес сортировочного центра или терминала.""" + + country: str + region: str + locality: str + fias: str + zip_code: str + lat: float + lng: float + address_row: str | None = None + building: str | None = None + floor: int | None = None + house: str | None = None + housing: str | None = None + locality_type: str | None = None + porch: str | None = None + room: str | None = None + street: str | None = None + sub_region: str | None = None + sub_region_type: str | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = { + "country": self.country, + "region": self.region, + "locality": self.locality, + "fias": self.fias, + "zipCode": self.zip_code, + "lat": self.lat, + "lng": self.lng, + } + if self.address_row is not None: + payload["addressRow"] = self.address_row + if self.building is not None: + payload["building"] = self.building + if self.floor is not None: + payload["floor"] = self.floor + if self.house is not None: + payload["house"] = self.house + if self.housing is not None: + payload["housing"] = self.housing + if self.locality_type is not None: + payload["localityType"] = self.locality_type + if self.porch is not None: + payload["porch"] = self.porch + if self.room is not None: + payload["room"] = self.room + if self.street is not None: + payload["street"] = self.street + if self.sub_region is not None: + payload["subRegion"] = self.sub_region + if self.sub_region_type is not None: + payload["subRegionType"] = self.sub_region_type + return payload + + +@dataclass(slots=True, frozen=True) +class DeliveryRestriction: + """Ограничения терминала или сортировочного центра.""" + + max_weight: int + max_dimensions: list[int] + max_declared_cost: int + dimensional_factor: int | None = None + max_dimensional_weight: int | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = { + "maxWeight": self.max_weight, + "maxDimensions": list(self.max_dimensions), + "maxDeclaredCost": self.max_declared_cost, + } + if self.dimensional_factor is not None: + payload["dimensionalFactor"] = self.dimensional_factor + if self.max_dimensional_weight is not None: + payload["maxDimensionalWeight"] = self.max_dimensional_weight + return payload + + +@dataclass(slots=True, frozen=True) +class WeeklySchedule: + """Недельное расписание работы.""" + + mon: list[str] + tue: list[str] + wed: list[str] + thu: list[str] + fri: list[str] + sat: list[str] + sun: list[str] + + def to_payload(self) -> dict[str, object]: + return { + "mon": list(self.mon), + "tue": list(self.tue), + "wed": list(self.wed), + "thu": list(self.thu), + "fri": list(self.fri), + "sat": list(self.sat), + "sun": list(self.sun), + } + + +@dataclass(slots=True, frozen=True) +class SortingCenterUpload: + """Сортировочный центр для загрузки в sandbox delivery API.""" + + delivery_provider_id: str + name: str + address: DeliveryAddress + phones: list[str] + itinerary: str + photos: list[str] + schedule: WeeklySchedule + restriction: DeliveryRestriction + direction_tag: str | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = { + "deliveryProviderId": self.delivery_provider_id, + "name": self.name, + "address": self.address.to_payload(), + "phones": list(self.phones), + "itinerary": self.itinerary, + "photos": list(self.photos), + "schedule": self.schedule.to_payload(), + "restriction": self.restriction.to_payload(), + } + if self.direction_tag is not None: + payload["directionTag"] = self.direction_tag + return payload + + +@dataclass(slots=True, frozen=True) +class AddSortingCentersRequest: + """Запрос загрузки сортировочных центров.""" + + items: list[SortingCenterUpload] + + def to_payload(self) -> list[dict[str, object]]: + return [item.to_payload() for item in self.items] + + +@dataclass(slots=True, frozen=True) +class TaggedSortingCenter: + """Тэг для сортировочного центра.""" + + delivery_provider_id: str + direction_tag: str + + def to_payload(self) -> dict[str, object]: + return { + "deliveryProviderId": self.delivery_provider_id, + "directionTag": self.direction_tag, + } + + +@dataclass(slots=True, frozen=True) +class TaggedSortingCentersRequest: + """Запрос установки тэгов сортировочным центрам.""" + + items: list[TaggedSortingCenter] + + def to_payload(self) -> list[dict[str, object]]: + return [item.to_payload() for item in self.items] + + +@dataclass(slots=True, frozen=True) +class TerminalUpload: + """Терминал для загрузки в sandbox delivery API.""" + + delivery_provider_id: str + name: str + address: DeliveryAddress + phones: list[str] + itinerary: str + photos: list[str] + direction_tag: str + services: list[str] + schedule: WeeklySchedule + restriction: DeliveryRestriction + display_name: str | None = None + options: list[str] | None = None + terminal_type: str | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = { + "deliveryProviderId": self.delivery_provider_id, + "name": self.name, + "address": self.address.to_payload(), + "phones": list(self.phones), + "itinerary": self.itinerary, + "photos": list(self.photos), + "directionTag": self.direction_tag, + "services": list(self.services), + "schedule": self.schedule.to_payload(), + "restriction": self.restriction.to_payload(), + } + if self.display_name is not None: + payload["displayName"] = self.display_name + if self.options is not None: + payload["options"] = list(self.options) + if self.terminal_type is not None: + payload["type"] = self.terminal_type + return payload + + +@dataclass(slots=True, frozen=True) +class AddTerminalsRequest: + """Запрос загрузки терминалов.""" + + items: list[TerminalUpload] + + def to_payload(self) -> list[dict[str, object]]: + return [item.to_payload() for item in self.items] + + +@dataclass(slots=True, frozen=True) +class DeliveryTermsZone: + """Зона сроков доставки.""" + + delivery_provider_zone_id: str | None = None + min_term: int | None = None + max_term: int | None = None + name: str | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {} + if self.delivery_provider_zone_id is not None: + payload["deliveryProviderZoneId"] = self.delivery_provider_zone_id + if self.min_term is not None: + payload["minTerm"] = self.min_term + if self.max_term is not None: + payload["maxTerm"] = self.max_term + if self.name is not None: + payload["name"] = self.name + return payload + + +@dataclass(slots=True, frozen=True) +class UpdateTermsRequest: + """Запрос обновления сроков по тарифу.""" + + items: list[DeliveryTermsZone] + + def to_payload(self) -> list[dict[str, object]]: + return [item.to_payload() for item in self.items] + + +@dataclass(slots=True, frozen=True) +class DeliveryDirectionZone: + """Условия доставки внутри направления тарифа.""" + + tariff_zone_id: str | None = None + terms_zone_id: str | None = None + type: str | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {} + if self.tariff_zone_id is not None: + payload["tariffZoneId"] = self.tariff_zone_id + if self.terms_zone_id is not None: + payload["termsZoneId"] = self.terms_zone_id + if self.type is not None: + payload["type"] = self.type + return payload + + +@dataclass(slots=True, frozen=True) +class DeliveryDirection: + """Направление доставки в тарифе.""" + + provider_direction_id: str + tag_from: str + tag_to: str + zones: list[DeliveryDirectionZone] + + def to_payload(self) -> dict[str, object]: + return { + "providerDirectionId": self.provider_direction_id, + "tagFrom": self.tag_from, + "tagTo": self.tag_to, + "zones": [zone.to_payload() for zone in self.zones], + } + + +@dataclass(slots=True, frozen=True) +class DeliveryTariffValue: + """Значение внутри модели расчета тарифной зоны.""" + + cost: int | None = None + max_weight: int | None = None + dimensional_factor: int | None = None + max_declared_cost: int | None = None + percent: float | None = None + min_cost: int | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {} + if self.cost is not None: + payload["cost"] = self.cost + if self.max_weight is not None: + payload["maxWeight"] = self.max_weight + if self.dimensional_factor is not None: + payload["dimensionalFactor"] = self.dimensional_factor + if self.max_declared_cost is not None: + payload["maxDeclaredCost"] = self.max_declared_cost + if self.percent is not None: + payload["percent"] = self.percent + if self.min_cost is not None: + payload["minCost"] = self.min_cost + return payload + + +@dataclass(slots=True, frozen=True) +class DeliveryTariffItem: + """Модель расчета стоимости услуги в тарифной зоне.""" + + calculation_mechanic: str + chargeable_parameter: str + service_name: str + values: list[DeliveryTariffValue] + + def to_payload(self) -> dict[str, object]: + return { + "calculationMechanic": self.calculation_mechanic, + "chargeableParameter": self.chargeable_parameter, + "serviceName": self.service_name, + "values": [value.to_payload() for value in self.values], + } + + +@dataclass(slots=True, frozen=True) +class DeliveryTariffZone: + """Тарифная зона доставки.""" + + name: str + delivery_provider_zone_id: str + items: list[DeliveryTariffItem] + + def to_payload(self) -> dict[str, object]: + return { + "name": self.name, + "deliveryProviderZoneId": self.delivery_provider_zone_id, + "items": [item.to_payload() for item in self.items], + } + + +@dataclass(slots=True, frozen=True) +class AddTariffV2Request: + """Запрос загрузки тарифа sandbox delivery API.""" + + name: str + delivery_provider_tariff_id: str + directions: list[DeliveryDirection] + tariff_zones: list[DeliveryTariffZone] + terms_zones: list[DeliveryTermsZone] + tariff_type: str | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = { + "name": self.name, + "deliveryProviderTariffId": self.delivery_provider_tariff_id, + "directions": [direction.to_payload() for direction in self.directions], + "tariffZones": [zone.to_payload() for zone in self.tariff_zones], + "termsZones": [zone.to_payload() for zone in self.terms_zones], + } + if self.tariff_type is not None: + payload["tariffType"] = self.tariff_type + return payload + + +@dataclass(slots=True, frozen=True) +class SandboxCancelAnnouncementOptions: + """Опции отмены тестового анонса.""" + + url_to_cancel_announcement: str + + def to_payload(self) -> dict[str, object]: + return {"urlToCancelAnnouncement": self.url_to_cancel_announcement} + + +@dataclass(slots=True, frozen=True) +class SandboxCancelAnnouncementRequest: + """Запрос отмены тестового анонса.""" + + announcement_id: str + date: str + options: SandboxCancelAnnouncementOptions + + def to_payload(self) -> dict[str, object]: + return { + "announcementID": self.announcement_id, + "date": self.date, + "options": self.options.to_payload(), + } + + +@dataclass(slots=True, frozen=True) +class CancelSandboxParcelOptions: + """Опции отмены тестовой посылки.""" + + cancelation_url: str + + def to_payload(self) -> dict[str, object]: + return {"cancelationUrl": self.cancelation_url} + + +@dataclass(slots=True, frozen=True) +class CancelSandboxParcelRequest: + """Запрос отмены тестовой посылки.""" + + parcel_id: str + options: CancelSandboxParcelOptions | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {"parcelID": self.parcel_id} + if self.options is not None: + payload["options"] = self.options.to_payload() + return payload + + +@dataclass(slots=True, frozen=True) +class ChangeParcelApplication: + """Изменяемые данные по посылке.""" + + kind: str | None = None + name: str | None = None + phones: list[str] | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {} + if self.kind is not None: + payload["kind"] = self.kind + if self.name is not None: + payload["name"] = self.name + if self.phones is not None: + payload["phones"] = list(self.phones) + return payload + + +@dataclass(slots=True, frozen=True) +class ChangeParcelOptions: + """Опции создания заявки на изменение посылки.""" + + change_parcel_url: str + + def to_payload(self) -> dict[str, object]: + return {"changeParcelUrl": self.change_parcel_url} + + +@dataclass(slots=True, frozen=True) +class ChangeParcelRequest: + """Запрос создания заявки на изменение тестовой посылки.""" + + type: str + parcel_id: str + application: ChangeParcelApplication | None = None + options: ChangeParcelOptions | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {"type": self.type, "parcelID": self.parcel_id} + if self.application is not None: + payload["application"] = self.application.to_payload() + if self.options is not None: + payload["options"] = self.options.to_payload() + return payload + + +@dataclass(slots=True, frozen=True) +class SandboxCreateAnnouncementOptions: + """Опции создания тестового анонса.""" + + url_to_send_announcement: str + + def to_payload(self) -> dict[str, object]: + return {"urlToSendAnnouncement": self.url_to_send_announcement} + + +@dataclass(slots=True, frozen=True) +class SandboxDeliveryPoint: + """Точка отправки или приема в тестовом анонсе.""" + + provider: str + point_id: str | None = None + accuracy: str | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {"provider": self.provider} + if self.point_id is not None: + payload["id"] = self.point_id + if self.accuracy is not None: + payload["accuracy"] = self.accuracy + return payload + + +@dataclass(slots=True, frozen=True) +class SandboxAnnouncementDelivery: + """Логистическая точка участника тестового анонса.""" + + type: str + terminal: SandboxDeliveryPoint | None = None + sorting_center: SandboxDeliveryPoint | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {"type": self.type} + if self.terminal is not None: + payload["terminal"] = self.terminal.to_payload() + if self.sorting_center is not None: + payload["sortingCenter"] = self.sorting_center.to_payload() + return payload + + +@dataclass(slots=True, frozen=True) +class SandboxAnnouncementParticipant: + """Участник тестового анонса.""" + + type: str + phones: list[str] + email: str + name: str + delivery: SandboxAnnouncementDelivery + + def to_payload(self) -> dict[str, object]: + return { + "type": self.type, + "phones": list(self.phones), + "email": self.email, + "name": self.name, + "delivery": self.delivery.to_payload(), + } + + +@dataclass(slots=True, frozen=True) +class SandboxAnnouncementPackage: + """Грузоместо в тестовом анонсе.""" + + package_id: str + parcel_ids: list[str] + seal_id: str | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = { + "id": self.package_id, + "parcelIDs": list(self.parcel_ids), + } + if self.seal_id is not None: + payload["sealID"] = self.seal_id + return payload + + +@dataclass(slots=True, frozen=True) +class SandboxCreateAnnouncementRequest: + """Запрос создания тестового анонса.""" + + announcement_id: str + barcode: str + sender: SandboxAnnouncementParticipant + receiver: SandboxAnnouncementParticipant + announcement_type: str + date: str + packages: list[SandboxAnnouncementPackage] + options: SandboxCreateAnnouncementOptions + + def to_payload(self) -> dict[str, object]: + return { + "announcementID": self.announcement_id, + "barcode": self.barcode, + "sender": self.sender.to_payload(), + "receiver": self.receiver.to_payload(), + "announcementType": self.announcement_type, + "date": self.date, + "packages": [package.to_payload() for package in self.packages], + "options": self.options.to_payload(), + } + + +@dataclass(slots=True, frozen=True) +class SandboxGetAnnouncementEventRequest: + """Запрос последнего события тестового анонса.""" + + announcement_id: str + + def to_payload(self) -> dict[str, object]: + return {"announcementID": self.announcement_id} + + +@dataclass(slots=True, frozen=True) +class GetChangeParcelInfoRequest: + """Запрос информации о заявке на изменение посылки.""" + + application_id: str + + def to_payload(self) -> dict[str, object]: + return {"applicationID": self.application_id} + + +@dataclass(slots=True, frozen=True) +class GetSandboxParcelInfoRequest: + """Запрос информации о тестовой посылке.""" + + parcel_id: str + + def to_payload(self) -> dict[str, object]: + return {"parcelID": self.parcel_id} + + +@dataclass(slots=True, frozen=True) +class GetRegisteredParcelIdRequest: + """Запрос ID зарегистрированной тестовой посылки.""" + + order_id: str + + def to_payload(self) -> dict[str, object]: + return {"orderID": self.order_id} + + @dataclass(slots=True, frozen=True) class SandboxAreasRequest: """Запрос добавления зон sandbox-доставки.""" @@ -210,7 +1025,6 @@ class OrderSummary: created_at: str | None buyer_name: str | None total_price: int | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -219,7 +1033,6 @@ class OrdersResult: items: list[OrderSummary] total: int | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -230,7 +1043,6 @@ class OrderActionResult: order_id: str | None = None status: str | None = None message: str | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -241,7 +1053,6 @@ class CourierRange: date: str | None start_at: str | None end_at: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -250,7 +1061,6 @@ class CourierRangesResult: items: list[CourierRange] address: str | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -259,7 +1069,6 @@ class LabelTaskResult: task_id: str | None status: str | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -297,7 +1106,6 @@ class DeliveryEntityResult: parcel_id: str | None = None status: str | None = None message: str | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -307,7 +1115,6 @@ class DeliverySortingCenter: sorting_center_id: str | None name: str | None city: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -315,7 +1122,6 @@ class DeliverySortingCentersResult: """Список сортировочных центров доставки.""" items: list[DeliverySortingCenter] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -325,7 +1131,6 @@ class DeliveryTaskInfo: task_id: str | None status: str | None error: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -337,7 +1142,6 @@ class StockInfo: is_multiple: bool | None is_unlimited: bool | None is_out_of_stock: bool | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -345,7 +1149,6 @@ class StockInfoResult: """Список текущих остатков.""" items: list[StockInfo] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -356,7 +1159,6 @@ class StockUpdateItem: external_id: str | None success: bool errors: list[str] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -364,7 +1166,6 @@ class StockUpdateResult: """Результат изменения остатков.""" items: list[StockUpdateItem] - _payload: Mapping[str, object] = field(default_factory=dict) enable_module_serialization(globals()) diff --git a/avito/promotion/client.py b/avito/promotion/client.py index 3d653d9..8d0eb6f 100644 --- a/avito/promotion/client.py +++ b/avito/promotion/client.py @@ -149,7 +149,9 @@ def create_order( ) -> PromotionActionResult: """Подключает BBIP-услугу.""" - payload_to_send = dict(request_payload) if request_payload is not None else request.to_payload() + payload_to_send = ( + dict(request_payload) if request_payload is not None else request.to_payload() + ) payload = self.transport.request_json( "PUT", "/promotion/v1/items/services/bbip/orders/create", @@ -192,7 +194,9 @@ def apply( ) -> PromotionActionResult: """Запускает TrxPromo.""" - payload_to_send = dict(request_payload) if request_payload is not None else request.to_payload() + payload_to_send = ( + dict(request_payload) if request_payload is not None else request.to_payload() + ) payload = self.transport.request_json( "POST", "/trx-promo/1/apply", @@ -216,7 +220,9 @@ def cancel( ) -> PromotionActionResult: """Останавливает TrxPromo.""" - payload_to_send = dict(request_payload) if request_payload is not None else request.to_payload() + payload_to_send = ( + dict(request_payload) if request_payload is not None else request.to_payload() + ) payload = self.transport.request_json( "POST", "/trx-promo/1/cancel", @@ -277,7 +283,9 @@ def create_item_bids( ) -> PromotionActionResult: """Сохраняет новые ставки.""" - payload_to_send = dict(request_payload) if request_payload is not None else request.to_payload() + payload_to_send = ( + dict(request_payload) if request_payload is not None else request.to_payload() + ) payload = self.transport.request_json( "POST", "/auction/1/bids", @@ -336,7 +344,9 @@ def delete_promotion( ) -> PromotionActionResult: """Останавливает продвижение с ценой целевого действия.""" - payload_to_send = dict(request_payload) if request_payload is not None else request.to_payload() + payload_to_send = ( + dict(request_payload) if request_payload is not None else request.to_payload() + ) payload = self.transport.request_json( "POST", "/cpxpromo/1/remove", @@ -360,7 +370,9 @@ def update_auto_bid( ) -> PromotionActionResult: """Применяет автоматическую настройку.""" - payload_to_send = dict(request_payload) if request_payload is not None else request.to_payload() + payload_to_send = ( + dict(request_payload) if request_payload is not None else request.to_payload() + ) payload = self.transport.request_json( "POST", "/cpxpromo/1/setAuto", @@ -384,7 +396,9 @@ def update_manual_bid( ) -> PromotionActionResult: """Применяет ручную настройку.""" - payload_to_send = dict(request_payload) if request_payload is not None else request.to_payload() + payload_to_send = ( + dict(request_payload) if request_payload is not None else request.to_payload() + ) payload = self.transport.request_json( "POST", "/cpxpromo/1/setManual", diff --git a/avito/promotion/domain.py b/avito/promotion/domain.py index 5136bcd..1add877 100644 --- a/avito/promotion/domain.py +++ b/avito/promotion/domain.py @@ -133,9 +133,7 @@ def list_orders( ListPromotionOrdersRequest(item_ids=item_ids, order_ids=order_ids) ) - def get_order_status( - self, *, order_ids: list[str] | None = None - ) -> PromotionOrderStatusResult: + def get_order_status(self, *, order_ids: list[str] | None = None) -> PromotionOrderStatusResult: """Получает статусы заявок на продвижение.""" resolved_order_ids = order_ids or ( diff --git a/avito/promotion/mappers.py b/avito/promotion/mappers.py index 2660208..5a50eea 100644 --- a/avito/promotion/mappers.py +++ b/avito/promotion/mappers.py @@ -112,11 +112,9 @@ def map_promotion_service_dictionary(payload: object) -> PromotionServiceDiction PromotionServiceType( code=_str(item, "code", "serviceCode", "id"), title=_str(item, "title", "name", "description"), - _payload=item, ) for item in _items_payload(data) ], - _payload=data, ) @@ -132,11 +130,9 @@ def map_promotion_services(payload: object) -> PromotionServicesResult: service_name=_str(item, "serviceName", "name", "title"), price=_int(item, "price", "pricePenny"), status=_str(item, "status"), - _payload=item, ) for item in _items_payload(data) ], - _payload=data, ) @@ -236,7 +232,7 @@ def map_promotion_action( success=bool(item.get("success", True)), status=_str(item, "status"), message=_str(_mapping(item, "error"), "message") or _str(item, "message"), - _payload=item, + upstream_reference=_str(item, "orderId", "requestId", "promotionId", "id"), ) for item in items_payload ] @@ -246,7 +242,15 @@ def map_promotion_action( resolved_status = _resolve_action_status(payload=data, statuses=statuses, applied=applied) details: dict[str, object] = {} if items: - details["items"] = [item.to_dict() for item in items] + details["items"] = [ + { + "item_id": item.item_id, + "success": item.success, + "status": item.status, + "message": item.message, + } + for item in items + ] elif message := _str(data, "message", "status"): details["message"] = message return PromotionActionResult( @@ -258,7 +262,6 @@ def map_promotion_action( warnings=messages if not applied else [], upstream_reference=_extract_upstream_reference(data, items), details=details, - _payload=data, ) @@ -282,10 +285,8 @@ def _extract_upstream_reference( if reference is not None: return reference for item in items: - item_payload = _expect_mapping(item._payload) - reference = _str(item_payload, "orderId", "requestId", "promotionId", "id") - if reference is not None: - return reference + if item.upstream_reference is not None: + return item.upstream_reference return None @@ -299,11 +300,9 @@ def map_bbip_suggests(payload: object) -> BbipSuggestsResult: item_id=_int(item, "itemId", "itemID"), duration=_map_bbip_duration(_mapping(item, "duration")), budgets=[_map_bbip_budget(option) for option in _list(item, "budgets")], - _payload=item, ) for item in _items_payload(data) ], - _payload=data, ) @@ -312,7 +311,6 @@ def _map_bbip_budget(payload: Payload) -> BbipBudgetOption: price=_int(payload, "price"), old_price=_int(payload, "oldPrice"), is_recommended=_bool(payload, "isRecommended"), - _payload=payload, ) @@ -323,7 +321,6 @@ def _map_bbip_duration(payload: Payload) -> BbipDurationRange | None: start=_int(payload, "from"), stop=_int(payload, "to"), recommended=_int(payload, "recommended"), - _payload=payload, ) @@ -342,11 +339,9 @@ def map_trx_commissions(payload: object) -> TrxCommissionsResult: commission=_int(item, "commission"), is_active=_bool(item, "isActive", "active"), valid_commission_range=_map_trx_range(_mapping(item, "validCommissionRange")), - _payload=item, ) for item in items_payload ], - _payload=data, ) @@ -357,7 +352,6 @@ def _map_trx_range(payload: Payload) -> TrxCommissionRange | None: value_min=_int(payload, "valueMin"), value_max=_int(payload, "valueMax"), step=_int(payload, "step"), - _payload=payload, ) @@ -375,15 +369,12 @@ def map_cpa_auction_bids(payload: object) -> CpaAuctionBidsResult: CpaAuctionBidOption( price_penny=_int(option, "pricePenny"), goodness=_int(option, "goodness"), - _payload=option, ) for option in _list(item, "availablePrices") ], - _payload=item, ) for item in _items_payload(data) ], - _payload=data, ) @@ -418,9 +409,7 @@ def _map_target_action_manual(payload: Payload) -> TargetActionManualBids: min_limit_penny=_int(payload, "minLimitPenny"), max_limit_penny=_int(payload, "maxLimitPenny"), bids=[ - _map_target_action_bid(item) - for item in bids_payload or [] - if isinstance(item, Mapping) + _map_target_action_bid(item) for item in bids_payload or [] if isinstance(item, Mapping) ], ) @@ -484,7 +473,9 @@ def map_target_action_get_promotions_by_item_ids_out( data = _expect_mapping(payload) items_payload = data.get("items") if not isinstance(items_payload, list): - raise ResponseMappingError("Ответ getPromotionsByItemIds должен содержать массив `items`.", payload=payload) + raise ResponseMappingError( + "Ответ getPromotionsByItemIds должен содержать массив `items`.", payload=payload + ) items: list[TargetActionPromotion] = [] for item in items_payload: if not isinstance(item, Mapping): @@ -535,7 +526,6 @@ def map_autostrategy_budget(payload: object) -> AutostrategyBudget: minimal=_map_budget_point(_mapping(source, "minimal")), maximal=_map_budget_point(_mapping(source, "maximal")), price_ranges=[_map_price_range(item) for item in _list(source, "priceRanges")], - _payload=data, ) @@ -550,7 +540,6 @@ def _map_budget_point(payload: Payload) -> AutostrategyBudgetPoint | None: calls_to=_int(payload, "callsTo"), views_from=_int(payload, "viewsFrom"), views_to=_int(payload, "viewsTo"), - _payload=payload, ) @@ -563,7 +552,6 @@ def _map_price_range(payload: Payload) -> AutostrategyPriceRange: calls_to=_int(payload, "callsTo"), views_from=_int(payload, "viewsFrom"), views_to=_int(payload, "viewsTo"), - _payload=payload, ) @@ -574,7 +562,6 @@ def map_campaign_action(payload: object) -> CampaignActionResult: return CampaignActionResult( campaign_id=_int(data, "campaignId", "campaignID", "id"), status=_str(data, "status"), - _payload=data, ) @@ -590,7 +577,6 @@ def map_campaign_info(payload: object) -> CampaignInfo: budget=_int(source, "budget"), balance=_int(source, "balance"), title=_str(source, "title", "name"), - _payload=data, ) @@ -600,7 +586,6 @@ def map_campaigns(payload: object) -> CampaignsResult: data = _expect_mapping(payload) return CampaignsResult( items=[map_campaign_info(item) for item in _items_payload(data)], - _payload=data, ) @@ -614,5 +599,4 @@ def map_autostrategy_stat(payload: object) -> AutostrategyStat: views=_int(source, "views"), contacts=_int(source, "contacts", "leads"), spend=_int(source, "spend", "spendTotal"), - _payload=data, ) diff --git a/avito/promotion/models.py b/avito/promotion/models.py index 1a8d67c..a618ac0 100644 --- a/avito/promotion/models.py +++ b/avito/promotion/models.py @@ -14,7 +14,6 @@ class PromotionServiceType(SerializableModel): code: str | None title: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -22,7 +21,6 @@ class PromotionServiceDictionary(SerializableModel): """Словарь услуг продвижения.""" items: list[PromotionServiceType] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -46,7 +44,6 @@ class PromotionService(SerializableModel): service_name: str | None price: int | None status: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -54,7 +51,6 @@ class PromotionServicesResult(SerializableModel): """Список услуг продвижения.""" items: list[PromotionService] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -226,7 +222,7 @@ class PromotionActionItem(SerializableModel): success: bool status: str | None = None message: str | None = None - _payload: Mapping[str, object] = field(default_factory=dict) + upstream_reference: str | None = None @dataclass(slots=True, frozen=True) @@ -237,11 +233,10 @@ class PromotionActionResult(SerializableModel): target: Mapping[str, object] | None status: str applied: bool - request_payload: Mapping[str, object] + request_payload: Mapping[str, object] | None = None warnings: list[str] = field(default_factory=list) upstream_reference: str | None = None details: Mapping[str, object] = field(default_factory=dict) - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -263,7 +258,6 @@ class BbipBudgetOption(SerializableModel): price: int | None old_price: int | None is_recommended: bool | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -273,7 +267,6 @@ class BbipDurationRange(SerializableModel): start: int | None stop: int | None recommended: int | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -283,7 +276,6 @@ class BbipSuggest(SerializableModel): item_id: int | None duration: BbipDurationRange | None budgets: list[BbipBudgetOption] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -291,7 +283,6 @@ class BbipSuggestsResult(SerializableModel): """Результат вариантов бюджета BBIP.""" items: list[BbipSuggest] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -347,7 +338,6 @@ class TrxCommissionRange(SerializableModel): value_min: int | None value_max: int | None step: int | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -358,7 +348,6 @@ class TrxCommissionInfo(SerializableModel): commission: int | None is_active: bool | None valid_commission_range: TrxCommissionRange | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -366,7 +355,6 @@ class TrxCommissionsResult(SerializableModel): """Список комиссий TrxPromo.""" items: list[TrxCommissionInfo] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -375,7 +363,6 @@ class CpaAuctionBidOption(SerializableModel): price_penny: int | None goodness: int | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -386,7 +373,6 @@ class CpaAuctionItemBid(SerializableModel): price_penny: int | None expiration_time: str | None available_prices: list[CpaAuctionBidOption] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -394,7 +380,6 @@ class CpaAuctionBidsResult(SerializableModel): """Список ставок CPA-аукциона.""" items: list[CpaAuctionItemBid] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -589,7 +574,6 @@ class AutostrategyBudgetPoint: calls_to: int | None views_from: int | None views_to: int | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -603,7 +587,6 @@ class AutostrategyPriceRange: calls_to: int | None views_from: int | None views_to: int | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -615,7 +598,6 @@ class AutostrategyBudget: minimal: AutostrategyBudgetPoint | None maximal: AutostrategyBudgetPoint | None price_ranges: list[AutostrategyPriceRange] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -636,7 +618,6 @@ class CampaignActionResult: campaign_id: int | None status: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -649,7 +630,6 @@ class CampaignInfo: budget: int | None balance: int | None title: str | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -657,7 +637,6 @@ class CampaignsResult: """Список автокампаний.""" items: list[CampaignInfo] - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -668,7 +647,6 @@ class AutostrategyStat: views: int | None contacts: int | None spend: int | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) diff --git a/avito/ratings/domain.py b/avito/ratings/domain.py index 28ac355..ca999c1 100644 --- a/avito/ratings/domain.py +++ b/avito/ratings/domain.py @@ -40,9 +40,7 @@ class ReviewAnswer(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def create_review_answer_v1( - self, *, review_id: int, text: str - ) -> ReviewAnswerInfo: + def create_review_answer_v1(self, *, review_id: int, text: str) -> ReviewAnswerInfo: return RatingsClient(self.transport).create_review_answer_v1( CreateReviewAnswerRequest(review_id=review_id, text=text) ) diff --git a/avito/ratings/mappers.py b/avito/ratings/mappers.py index fc0e601..e784ef2 100644 --- a/avito/ratings/mappers.py +++ b/avito/ratings/mappers.py @@ -79,7 +79,6 @@ def map_review_answer(payload: object) -> ReviewAnswerInfo: answer_id=_str(data, "id"), created_at=_int(data, "createdAt"), success=_bool(data, "success"), - _payload=data, ) @@ -93,7 +92,6 @@ def map_rating_profile(payload: object) -> RatingProfileInfo: score=_float(rating, "score"), reviews_count=_int(rating, "reviewsCount"), reviews_with_score_count=_int(rating, "reviewsWithScoreCount"), - _payload=data, ) @@ -111,10 +109,8 @@ def map_reviews(payload: object) -> ReviewsResult: created_at=_int(item, "createdAt"), can_answer=_bool(item, "canAnswer"), used_in_score=_bool(item, "usedInScore"), - _payload=item, ) for item in _list(data, "reviews", "items") ], total=_int(data, "total"), - _payload=data, ) diff --git a/avito/ratings/models.py b/avito/ratings/models.py index 8243824..99590fa 100644 --- a/avito/ratings/models.py +++ b/avito/ratings/models.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass from avito.core.serialization import enable_module_serialization @@ -47,7 +46,6 @@ class ReviewInfo: created_at: int | None can_answer: bool | None used_in_score: bool | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -56,7 +54,6 @@ class ReviewsResult: items: list[ReviewInfo] total: int | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -66,7 +63,6 @@ class ReviewAnswerInfo: answer_id: str | None = None created_at: int | None = None success: bool | None = None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -77,7 +73,6 @@ class RatingProfileInfo: score: float | None = None reviews_count: int | None = None reviews_with_score_count: int | None = None - _payload: Mapping[str, object] = field(default_factory=dict) enable_module_serialization(globals()) diff --git a/avito/realty/client.py b/avito/realty/client.py index 6a6304a..394b5a0 100644 --- a/avito/realty/client.py +++ b/avito/realty/client.py @@ -10,9 +10,9 @@ RealtyActionResult, RealtyAnalyticsInfo, RealtyBaseParamsUpdateRequest, - RealtyBookingsUpdateRequest, RealtyBookingsQuery, RealtyBookingsResult, + RealtyBookingsUpdateRequest, RealtyIntervalsRequest, RealtyMarketPriceInfo, RealtyPricesUpdateRequest, diff --git a/avito/realty/domain.py b/avito/realty/domain.py index 3f7f56e..303cacc 100644 --- a/avito/realty/domain.py +++ b/avito/realty/domain.py @@ -10,9 +10,9 @@ RealtyActionResult, RealtyAnalyticsInfo, RealtyBaseParamsUpdateRequest, - RealtyBookingsUpdateRequest, RealtyBookingsQuery, RealtyBookingsResult, + RealtyBookingsUpdateRequest, RealtyIntervalsRequest, RealtyMarketPriceInfo, RealtyPricesUpdateRequest, diff --git a/avito/tariffs/mappers.py b/avito/tariffs/mappers.py index 5b4d836..f46362e 100644 --- a/avito/tariffs/mappers.py +++ b/avito/tariffs/mappers.py @@ -76,7 +76,6 @@ def _map_contract(payload: Payload) -> TariffContractInfo | None: price=_float(price, "price"), original_price=_float(price, "originalPrice"), packages_count=packages_count, - _payload=payload, ) @@ -87,5 +86,4 @@ def map_tariff_info(payload: object) -> TariffInfo: return TariffInfo( current=_map_contract(_mapping(data, "current")), scheduled=_map_contract(_mapping(data, "scheduled")), - _payload=data, ) diff --git a/avito/tariffs/models.py b/avito/tariffs/models.py index 5ab580b..fcf0b33 100644 --- a/avito/tariffs/models.py +++ b/avito/tariffs/models.py @@ -2,8 +2,7 @@ from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass from avito.core.serialization import enable_module_serialization @@ -20,7 +19,6 @@ class TariffContractInfo: price: float | None original_price: float | None packages_count: int | None - _payload: Mapping[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -29,7 +27,6 @@ class TariffInfo: current: TariffContractInfo | None = None scheduled: TariffContractInfo | None = None - _payload: Mapping[str, object] = field(default_factory=dict) enable_module_serialization(globals()) diff --git a/tests/fake_transport.py b/tests/fake_transport.py index 15ef548..37c8bbd 100644 --- a/tests/fake_transport.py +++ b/tests/fake_transport.py @@ -83,7 +83,9 @@ def build( return Transport( settings, auth_provider=None, - client=httpx.Client(transport=httpx.MockTransport(self._handle), base_url=self.base_url), + client=httpx.Client( + transport=httpx.MockTransport(self._handle), base_url=self.base_url + ), sleep=lambda _: None, ) @@ -122,7 +124,9 @@ def _handle(self, request: httpx.Request) -> httpx.Response: 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(f"Unexpected request {recorded.method} {recorded.path}. Known: {available}") + raise AssertionError( + f"Unexpected request {recorded.method} {recorded.path}. Known: {available}" + ) responders = self._routes[key] responder = responders[0] if len(responders) == 1 else responders.popleft() diff --git a/tests/test_core.py b/tests/test_core.py index e946477..b44fc51 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -178,7 +178,9 @@ def test_transport_handles_rate_limit_and_classifies_errors() -> None: ) with pytest.raises(AuthorizationError): - authorization_transport.request_json("GET", "/forbidden", context=RequestContext("forbidden")) + authorization_transport.request_json( + "GET", "/forbidden", context=RequestContext("forbidden") + ) conflict_transport = Transport( make_settings(retry_policy=RetryPolicy(max_attempts=1)), diff --git a/tests/test_no_raw_payload_contract.py b/tests/test_no_raw_payload_contract.py index 9937f7f..33abfa0 100644 --- a/tests/test_no_raw_payload_contract.py +++ b/tests/test_no_raw_payload_contract.py @@ -35,7 +35,7 @@ def iter_public_dataclasses() -> list[tuple[str, str, type[object]]]: def test_no_public_model_declares_raw_payload_field() -> None: offenders = [] for module_name, name, cls in iter_public_dataclasses(): - if any(field.name == "raw_payload" for field in fields(cls)): + if any(field.name in {"raw_payload", "_payload"} for field in fields(cls)): offenders.append(f"{module_name}:{name}") assert offenders == [] diff --git a/tests/test_promotion_contract_alignment.py b/tests/test_promotion_contract_alignment.py index 9b1f8f2..98f3d21 100644 --- a/tests/test_promotion_contract_alignment.py +++ b/tests/test_promotion_contract_alignment.py @@ -77,7 +77,9 @@ def handler(request: httpx.Request) -> httpx.Response: }, ) - result = TargetActionPricing(make_transport(httpx.MockTransport(handler)), resource_id=101).get_bids() + result = TargetActionPricing( + make_transport(httpx.MockTransport(handler)), resource_id=101 + ).get_bids() assert isinstance(result, TargetActionGetBidsResult) assert result.action_type_id == 5 diff --git a/tests/test_public_api_shape.py b/tests/test_public_api_shape.py index 69f99ce..5f612c6 100644 --- a/tests/test_public_api_shape.py +++ b/tests/test_public_api_shape.py @@ -2,12 +2,17 @@ import inspect -import avito.orders as orders import avito.autoteka as autoteka import avito.jobs as jobs -import avito.messenger as messenger +import avito.orders as orders import avito.realty as realty -from avito.autoteka import AutotekaMonitoring, AutotekaReport, AutotekaScoring, AutotekaValuation, AutotekaVehicle +from avito.autoteka import ( + AutotekaMonitoring, + AutotekaReport, + AutotekaScoring, + AutotekaValuation, + AutotekaVehicle, +) from avito.jobs import Application, JobWebhook, Resume, Vacancy from avito.messenger import ChatMedia from avito.orders import DeliveryOrder, Order, OrderLabel, SandboxDelivery, Stock diff --git a/tests/test_public_models.py b/tests/test_public_models.py index 7e8d290..ac0b2f2 100644 --- a/tests/test_public_models.py +++ b/tests/test_public_models.py @@ -44,7 +44,6 @@ def test_primary_sdk_models_serialize_without_transport_fields() -> None: name="Иван", email=None, phone="+7999", - _payload={"internal": "value"}, ) listing = Listing( id=101, @@ -54,21 +53,18 @@ def test_primary_sdk_models_serialize_without_transport_fields() -> None: status="active", price=1000.0, url=None, - _payload={"transport": True}, ) stats = ListingStats( item_id=101, views=42, contacts=None, favorites=3, - _payload={"transport": True}, ) calls = CallStats( item_id=101, calls=4, answered_calls=3, missed_calls=1, - _payload={"transport": True}, ) spendings = AccountSpendings( items=[ @@ -76,11 +72,9 @@ def test_primary_sdk_models_serialize_without_transport_fields() -> None: item_id=101, amount=77.5, service="xl", - _payload={"transport": True}, ) ], total=77.5, - _payload={"transport": True}, ) service = PromotionService( item_id=101, diff --git a/tests/test_read_contract.py b/tests/test_read_contract.py index a084fcc..46e6820 100644 --- a/tests/test_read_contract.py +++ b/tests/test_read_contract.py @@ -39,7 +39,9 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"id": 101, "user_id": 7, "title": "Смартфон"}) if path == "/core/v1/items": assert request.url.params["user_id"] == "7" - return httpx.Response(200, json={"items": [{"id": 101, "title": "Смартфон"}], "total": 1}) + return httpx.Response( + 200, json={"items": [{"id": 101, "title": "Смартфон"}], "total": 1} + ) if path == "/stats/v1/accounts/7/items": body = json.loads(request.content.decode()) assert body["itemIds"] == [101] diff --git a/tests/test_realty_contract_alignment.py b/tests/test_realty_contract_alignment.py index 3551fec..95bbe8b 100644 --- a/tests/test_realty_contract_alignment.py +++ b/tests/test_realty_contract_alignment.py @@ -94,7 +94,9 @@ def handler(request: httpx.Request) -> httpx.Response: ) assert result.items[0].booking_id == 777 - assert result.items[0].contact is not None and result.items[0].contact.email == "ivan@example.com" + assert ( + result.items[0].contact is not None and result.items[0].contact.email == "ivan@example.com" + ) assert result.items[0].safe_deposit is not None assert result.to_dict() == { "items": [ diff --git a/tests/test_stage11_mock_transport_suite.py b/tests/test_stage11_mock_transport_suite.py index 0143b5f..b7ceff0 100644 --- a/tests/test_stage11_mock_transport_suite.py +++ b/tests/test_stage11_mock_transport_suite.py @@ -118,7 +118,11 @@ def test_mock_transport_happy_path_read_methods_and_contract_snapshots( fake_transport.add_json( "POST", "/promotion/v1/items/services/bbip/forecasts/get", - {"items": [{"itemId": 101, "min": 10, "max": 25, "totalPrice": 7000, "totalOldPrice": 8400}]}, + { + "items": [ + {"itemId": 101, "min": 10, "max": 25, "totalPrice": 7000, "totalOldPrice": 8400} + ] + }, ) fake_transport.add_json( "POST", @@ -154,7 +158,15 @@ def test_mock_transport_happy_path_read_methods_and_contract_snapshots( fake_transport.add_json( "POST", "/cpxpromo/1/getPromotionsByItemIds", - {"items": [{"itemID": 102, "actionTypeID": 7, "autoPromotion": {"budgetPenny": 9000, "budgetType": "7d"}}]}, + { + "items": [ + { + "itemID": 102, + "actionTypeID": 7, + "autoPromotion": {"budgetPenny": 9000, "budgetType": "7d"}, + } + ] + }, ) transport = fake_transport.build() @@ -394,12 +406,12 @@ def test_mock_transport_happy_path_write_methods_and_dry_run( result.request_payload for result in previews ] assert all(result.applied is True for result in applied) - assert fake_transport.last(method="PUT", path="/core/v1/accounts/7/items/101/vas").json_body == { - "codes": ["xl"] - } - assert fake_transport.last(method="PUT", path="/promotion/v1/items/services/bbip/orders/create").json_body == { - "items": [{"itemId": 101, "duration": 7, "price": 1000, "oldPrice": 1200}] - } + assert fake_transport.last( + method="PUT", path="/core/v1/accounts/7/items/101/vas" + ).json_body == {"codes": ["xl"]} + assert fake_transport.last( + method="PUT", path="/promotion/v1/items/services/bbip/orders/create" + ).json_body == {"items": [{"itemId": 101, "duration": 7, "price": 1000, "oldPrice": 1200}]} assert applied[3].to_dict() == { "action": "create_order", "target": {"item_ids": [101]}, @@ -411,9 +423,7 @@ def test_mock_transport_happy_path_write_methods_and_dry_run( "warnings": [], "upstream_reference": "ord-1", "details": { - "items": [ - {"item_id": 101, "success": True, "status": "created", "message": None} - ] + "items": [{"item_id": 101, "success": True, "status": "created", "message": None}] }, } @@ -464,7 +474,10 @@ def test_mock_transport_pagination_is_lazy_and_propagates_later_page_errors() -> "GET", "/core/v1/items", json_response( - {"items": [{"id": 101, "title": "Смартфон"}, {"id": 102, "title": "Ноутбук"}], "total": 4} + { + "items": [{"id": 101, "title": "Смартфон"}, {"id": 102, "title": "Ноутбук"}], + "total": 4, + } ), httpx.Response(429, json={"message": "page 2 failed"}, headers={"retry-after": "1"}), ) diff --git a/tests/test_stage11_realty_ratings_tariffs.py b/tests/test_stage11_realty_ratings_tariffs.py index 5872648..3a1b994 100644 --- a/tests/test_stage11_realty_ratings_tariffs.py +++ b/tests/test_stage11_realty_ratings_tariffs.py @@ -66,7 +66,11 @@ def handler(request: httpx.Request) -> httpx.Response: "email": "ivan@example.com", "phone": "9997770000", }, - "safe_deposit": {"owner_amount": 4500, "tax": 500, "total_amount": 5000}, + "safe_deposit": { + "owner_amount": 4500, + "tax": 500, + "total_amount": 5000, + }, } ] }, diff --git a/tests/test_stage4_promotion_write_contract.py b/tests/test_stage4_promotion_write_contract.py index 3530ddb..b70d274 100644 --- a/tests/test_stage4_promotion_write_contract.py +++ b/tests/test_stage4_promotion_write_contract.py @@ -110,18 +110,32 @@ def handler(request: httpx.Request) -> httpx.Response: if path == "/promotion/v1/items/services/bbip/orders/create": return httpx.Response( 200, - json={"items": [{"itemId": 101, "success": True, "status": "created", "orderId": "ord-1"}]}, + json={ + "items": [ + {"itemId": 101, "success": True, "status": "created", "orderId": "ord-1"} + ] + }, ) if path == "/trx-promo/1/apply": - return httpx.Response(200, json={"success": {"items": [{"itemID": 101, "success": True}]}}) + return httpx.Response( + 200, json={"success": {"items": [{"itemID": 101, "success": True}]}} + ) if path == "/trx-promo/1/cancel": - return httpx.Response(200, json={"success": {"items": [{"itemID": 101, "success": True}]}}) + return httpx.Response( + 200, json={"success": {"items": [{"itemID": 101, "success": True}]}} + ) if path == "/cpxpromo/1/setAuto": - return httpx.Response(200, json={"items": [{"itemID": 101, "success": True, "status": "auto"}]}) + return httpx.Response( + 200, json={"items": [{"itemID": 101, "success": True, "status": "auto"}]} + ) if path == "/cpxpromo/1/setManual": - return httpx.Response(200, json={"items": [{"itemID": 101, "success": True, "status": "manual"}]}) + return httpx.Response( + 200, json={"items": [{"itemID": 101, "success": True, "status": "manual"}]} + ) assert path == "/cpxpromo/1/remove" - return httpx.Response(200, json={"items": [{"itemID": 101, "success": True, "status": "removed"}]}) + return httpx.Response( + 200, json={"items": [{"itemID": 101, "success": True, "status": "removed"}]} + ) transport = make_transport(httpx.MockTransport(handler)) ad_promotion = AdPromotion(transport, resource_id=101, user_id=7) @@ -203,9 +217,7 @@ def handler(request: httpx.Request) -> httpx.Response: "warnings": [], "upstream_reference": "ord-1", "details": { - "items": [ - {"item_id": 101, "success": True, "status": "created", "message": None} - ] + "items": [{"item_id": 101, "success": True, "status": "created", "message": None}] }, } @@ -213,7 +225,10 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.parametrize( ("call", "expected"), [ - (lambda resource: resource.apply_vas(codes=[], dry_run=True), "`codes` must contain at least one item."), + ( + lambda resource: resource.apply_vas(codes=[], dry_run=True), + "`codes` must contain at least one item.", + ), ( lambda resource: resource.apply_vas_package(package_code=" ", dry_run=True), "`package_code` must be a non-empty string.", diff --git a/tests/test_stage6_promotion.py b/tests/test_stage6_promotion.py index 57b0e65..50f96e8 100644 --- a/tests/test_stage6_promotion.py +++ b/tests/test_stage6_promotion.py @@ -387,16 +387,24 @@ def handler(request: httpx.Request) -> httpx.Response: campaign = AutostrategyCampaign(make_transport(httpx.MockTransport(handler)), resource_id=77) - budget = campaign.create_budget(request=CreateAutostrategyBudgetRequest(payload={"listingFee": 1000})) + budget = campaign.create_budget( + request=CreateAutostrategyBudgetRequest(payload={"listingFee": 1000}) + ) created = campaign.create( - request=CreateAutostrategyCampaignRequest(payload={"title": "Весенняя кампания", "budgetId": "budget-1"}) + request=CreateAutostrategyCampaignRequest( + payload={"title": "Весенняя кампания", "budgetId": "budget-1"} + ) ) updated = campaign.update( - request=UpdateAutostrategyCampaignRequest(payload={"campaignId": 77, "title": "Обновленная кампания"}) + request=UpdateAutostrategyCampaignRequest( + payload={"campaignId": 77, "title": "Обновленная кампания"} + ) ) info = campaign.get() stopped = campaign.delete() - campaigns = campaign.list(request=ListAutostrategyCampaignsRequest(payload={"status": "active"})) + campaigns = campaign.list( + request=ListAutostrategyCampaignsRequest(payload={"status": "active"}) + ) stat = campaign.get_stat() assert budget.budget_id == "budget-1" diff --git a/tests/test_stage7_orders.py b/tests/test_stage7_orders.py index d5cbfc1..072cf41 100644 --- a/tests/test_stage7_orders.py +++ b/tests/test_stage7_orders.py @@ -11,23 +11,64 @@ from avito.core.types import ApiTimeouts from avito.orders import DeliveryOrder, DeliveryTask, Order, OrderLabel, SandboxDelivery, Stock from avito.orders.models import ( + AddSortingCentersRequest, + AddTariffV2Request, + CancelParcelRequest, + CancelSandboxParcelRequest, + ChangeParcelRequest, + CustomAreaScheduleEntry, + CustomAreaScheduleRequest, + DeliveryAddress, DeliveryAnnouncementRequest, + DeliveryDateInterval, + DeliveryDirection, + DeliveryDirectionZone, DeliveryParcelIdsRequest, DeliveryParcelRequest, DeliveryParcelResultRequest, + DeliveryRestriction, + DeliveryTariffItem, + DeliveryTariffValue, + DeliveryTariffZone, + DeliveryTerms, + DeliveryTermsZone, + DeliveryTrackingRequest, + GetChangeParcelInfoRequest, + GetRegisteredParcelIdRequest, + GetSandboxParcelInfoRequest, OrderAcceptReturnRequest, OrderApplyTransitionRequest, OrderCncDetailsRequest, OrderConfirmationCodeRequest, OrderCourierRangeRequest, + OrderDeliveryProperties, OrderLabelsRequest, OrderMarkingsRequest, OrderTrackingNumberRequest, + ProhibitOrderAcceptanceRequest, + RealAddress, + SandboxAnnouncementDelivery, + SandboxAnnouncementPackage, + SandboxAnnouncementParticipant, SandboxArea, SandboxAreasRequest, + SandboxCancelAnnouncementOptions, + SandboxCancelAnnouncementRequest, + SandboxConfirmationCodeRequest, + SandboxCreateAnnouncementOptions, + SandboxCreateAnnouncementRequest, + SandboxDeliveryPoint, + SandboxGetAnnouncementEventRequest, + SetOrderPropertiesRequest, + SetOrderRealAddressRequest, + SortingCenterUpload, StockInfoRequest, StockUpdateEntry, StockUpdateRequest, + TaggedSortingCenter, + TaggedSortingCentersRequest, + UpdateTermsRequest, + WeeklySchedule, ) @@ -198,6 +239,50 @@ def handler(request: httpx.Request) -> httpx.Response: if path == "/delivery-sandbox/announcements/track": assert payload == {"orderId": "sand-1"} return httpx.Response(200, json={"data": {"status": "tracked"}}) + if path == "/delivery-sandbox/areas/custom-schedule": + assert payload == [ + { + "providerAreaNumber": ["area-1"], + "services": ["delivery"], + "customSchedule": [{"date": "2026-04-20", "intervals": ["10:00:00/18:00:00"]}], + } + ] + return httpx.Response(200, json={"data": {"status": "schedule-updated"}}) + if path == "/delivery-sandbox/cancelParcel": + assert payload == {"parcelID": "spar-1", "actor": "receiver"} + return httpx.Response(200, json={"data": {"status": "parcel-cancelled"}}) + if path == "/delivery-sandbox/order/checkConfirmationCode": + assert payload == {"parcelID": "spar-1", "confirmCode": "1234"} + return httpx.Response(200, json={"data": {"status": "success"}}) + if path == "/delivery-sandbox/order/properties": + assert payload == { + "orderId": "sand-1", + "properties": { + "delivery": {"cost": 19900}, + "dimensions": [10, 20, 30], + "weight": 500, + }, + } + return httpx.Response(200, json={"data": {"status": "properties-set"}}) + if path == "/delivery-sandbox/order/realAddress": + assert payload == { + "orderId": "sand-1", + "address": {"addressType": "SENDER_SEND", "terminalNumber": "term-1"}, + } + return httpx.Response(200, json={"data": {"status": "real-address-set"}}) + if path == "/delivery-sandbox/order/tracking": + assert payload == { + "orderId": "sand-1", + "avitoStatus": "IN_TRANSIT", + "avitoEventType": "RECEIVED_AT_TRANSIT_TERMINAL", + "providerEventCode": "evt-1", + "date": "2026-04-20T10:00:00Z", + "location": "Москва", + } + return httpx.Response(200, json={"data": {"status": "tracking-saved"}}) + if path == "/delivery-sandbox/prohibitOrderAcceptance": + assert payload == {"orderId": "sand-1"} + return httpx.Response(200, json={"data": {"status": "acceptance-prohibited"}}) if path == "/delivery-sandbox/sorting-center": return httpx.Response( 200, @@ -207,9 +292,150 @@ def handler(request: httpx.Request) -> httpx.Response: } }, ) + if path == "/delivery-sandbox/tariffs/sorting-center": + assert payload == [ + { + "deliveryProviderId": "sc-1", + "name": "Центр 1", + "address": { + "country": "Россия", + "region": "Москва", + "locality": "Москва", + "fias": "fias-1", + "zipCode": "101000", + "lat": 55.75, + "lng": 37.61, + }, + "phones": ["79990000000"], + "itinerary": "Вход справа", + "photos": [], + "schedule": { + "mon": ["09:00:00/18:00:00"], + "tue": ["09:00:00/18:00:00"], + "wed": ["09:00:00/18:00:00"], + "thu": ["09:00:00/18:00:00"], + "fri": ["09:00:00/18:00:00"], + "sat": [], + "sun": [], + }, + "restriction": { + "maxWeight": 10000, + "maxDimensions": [100, 50, 50], + "maxDeclaredCost": 100000, + }, + "directionTag": "moscow", + } + ] + return httpx.Response( + 200, json={"data": {"taskId": 62, "status": "sorting-centers-added"}} + ) if path == "/delivery-sandbox/tariffs/tf-1/areas": assert payload == {"areas": [{"city": "Москва"}]} return httpx.Response(200, json={"data": {"taskId": 61, "status": "areas-added"}}) + if path == "/delivery-sandbox/tariffs/tf-1/tagged-sorting-centers": + assert payload == [{"deliveryProviderId": "sc-1", "directionTag": "moscow"}] + return httpx.Response(200, json={"data": {"status": "tags-added"}}) + if path == "/delivery-sandbox/tariffs/tf-1/terms": + assert payload == [ + { + "deliveryProviderZoneId": "zone-1", + "minTerm": 1, + "maxTerm": 2, + "name": "zone", + } + ] + return httpx.Response(200, json={"data": {"status": "terms-updated"}}) + if path == "/delivery-sandbox/tariffsV2": + assert payload == { + "name": "Tariff", + "deliveryProviderTariffId": "tariff-1", + "directions": [ + { + "providerDirectionId": "dir-1", + "tagFrom": "moscow", + "tagTo": "spb", + "zones": [{"tariffZoneId": "tz-1", "termsZoneId": "term-1", "type": "0"}], + } + ], + "tariffZones": [ + { + "name": "Zone", + "deliveryProviderZoneId": "tz-1", + "items": [ + { + "calculationMechanic": "GAP_TO_COST", + "chargeableParameter": "WEIGHT", + "serviceName": "DELIVERY", + "values": [{"cost": 10000, "maxWeight": 3000}], + } + ], + } + ], + "termsZones": [ + { + "deliveryProviderZoneId": "term-1", + "minTerm": 1, + "maxTerm": 2, + "name": "term", + } + ], + } + return httpx.Response(200, json={"data": {"taskId": 63, "status": "tariff-added"}}) + if path == "/delivery-sandbox/v1/cancelAnnouncement": + assert payload == { + "announcementID": "ann-1", + "date": "2026-04-20T10:00:00Z", + "options": {"urlToCancelAnnouncement": "https://example.com/cancel"}, + } + return httpx.Response(200, json={"data": {"status": "announcement-cancel-requested"}}) + if path == "/delivery-sandbox/v1/cancelParcel": + assert payload == {"parcelID": "spar-1"} + return httpx.Response(200, json={"data": {"status": "sandbox-parcel-cancelled"}}) + if path == "/delivery-sandbox/v1/changeParcel": + assert payload == {"type": "changeReceiver", "parcelID": "spar-1"} + return httpx.Response(200, json={"data": {"status": "change-created"}}) + if path == "/delivery-sandbox/v1/createAnnouncement": + assert payload == { + "announcementID": "ann-1", + "barcode": "barcode-1", + "sender": { + "type": "3PL", + "phones": ["79990000000"], + "email": "sender@example.com", + "name": "Sender", + "delivery": { + "type": "TERMINAL", + "terminal": {"provider": "avito", "id": "term-1"}, + }, + }, + "receiver": { + "type": "ABD", + "phones": ["79990000001"], + "email": "receiver@example.com", + "name": "Receiver", + "delivery": { + "type": "TERMINAL", + "terminal": {"provider": "avito", "id": "term-2"}, + }, + }, + "announcementType": "DELIVERY", + "date": "2026-04-20T10:00:00Z", + "packages": [{"id": "pkg-1", "parcelIDs": ["spar-1"]}], + "options": {"urlToSendAnnouncement": "https://example.com/announce"}, + } + return httpx.Response(200, json={"data": {"status": "announcement-created-v1"}}) + if path == "/delivery-sandbox/v1/getAnnouncementEvent": + assert payload == {"announcementID": "ann-1"} + return httpx.Response(200, json={"data": {"status": "event-ready"}}) + if path == "/delivery-sandbox/v1/getChangeParcelInfo": + assert payload == {"applicationID": "app-1"} + return httpx.Response(200, json={"data": {"status": "change-info-ready"}}) + if path == "/delivery-sandbox/v1/getParcelInfo": + assert payload == {"parcelID": "spar-1"} + return httpx.Response(200, json={"data": {"status": "parcel-info-ready"}}) + if path == "/delivery-sandbox/v1/getRegisteredParcelID": + assert payload == {"orderID": "sand-1"} + return httpx.Response(200, json={"data": {"parcelId": "reg-1", "status": "registered"}}) if path == "/delivery-sandbox/v2/createParcel": assert payload == {"orderId": "sand-1", "parcelId": "spar-1"} return httpx.Response( @@ -223,7 +449,9 @@ def handler(request: httpx.Request) -> httpx.Response: sandbox = SandboxDelivery(transport, resource_id="sand-1") task = DeliveryTask(transport, resource_id="51") - announcement = delivery.create_announcement(request=DeliveryAnnouncementRequest(order_id="ord-1")) + announcement = delivery.create_announcement( + request=DeliveryAnnouncementRequest(order_id="ord-1") + ) parcel = delivery.create(request=DeliveryParcelRequest(order_id="ord-1", parcel_id="par-1")) cancelled = delivery.delete(request=DeliveryAnnouncementRequest(order_id="ord-1")) callback = delivery.create_change_parcel_result( @@ -234,11 +462,214 @@ def handler(request: httpx.Request) -> httpx.Response: request=DeliveryAnnouncementRequest(order_id="sand-1") ) tracked = sandbox.track_announcement(request=DeliveryAnnouncementRequest(order_id="sand-1")) + schedule = sandbox.update_custom_area_schedule( + request=CustomAreaScheduleRequest( + items=[ + CustomAreaScheduleEntry( + provider_area_numbers=["area-1"], + services=["delivery"], + custom_schedule=[ + DeliveryDateInterval( + date="2026-04-20", + intervals=["10:00:00/18:00:00"], + ) + ], + ) + ] + ) + ) + cancelled_parcel = sandbox.cancel_parcel( + request=CancelParcelRequest(parcel_id="spar-1", actor="receiver") + ) + sandbox_code_checked = sandbox.check_confirmation_code( + request=SandboxConfirmationCodeRequest(parcel_id="spar-1", confirm_code="1234") + ) + props = sandbox.set_order_properties( + request=SetOrderPropertiesRequest( + order_id="sand-1", + properties=OrderDeliveryProperties( + delivery=DeliveryTerms(cost=19900), + dimensions=[10, 20, 30], + weight=500, + ), + ) + ) + real_address = sandbox.set_order_real_address( + request=SetOrderRealAddressRequest( + order_id="sand-1", + address=RealAddress(address_type="SENDER_SEND", terminal_number="term-1"), + ) + ) + tracking = sandbox.tracking( + request=DeliveryTrackingRequest( + order_id="sand-1", + avito_status="IN_TRANSIT", + avito_event_type="RECEIVED_AT_TRANSIT_TERMINAL", + provider_event_code="evt-1", + date="2026-04-20T10:00:00Z", + location="Москва", + ) + ) + prohibited = sandbox.prohibit_order_acceptance( + request=ProhibitOrderAcceptanceRequest(order_id="sand-1") + ) centers = sandbox.list_sorting_center() + address = DeliveryAddress( + country="Россия", + region="Москва", + locality="Москва", + fias="fias-1", + zip_code="101000", + lat=55.75, + lng=37.61, + ) + schedule_model = WeeklySchedule( + mon=["09:00:00/18:00:00"], + tue=["09:00:00/18:00:00"], + wed=["09:00:00/18:00:00"], + thu=["09:00:00/18:00:00"], + fri=["09:00:00/18:00:00"], + sat=[], + sun=[], + ) + restriction = DeliveryRestriction( + max_weight=10000, + max_dimensions=[100, 50, 50], + max_declared_cost=100000, + ) + sorting_centers_added = sandbox.add_sorting_center( + request=AddSortingCentersRequest( + items=[ + SortingCenterUpload( + delivery_provider_id="sc-1", + name="Центр 1", + address=address, + phones=["79990000000"], + itinerary="Вход справа", + photos=[], + schedule=schedule_model, + restriction=restriction, + direction_tag="moscow", + ) + ] + ) + ) added_areas = sandbox.add_areas( tariff_id="tf-1", request=SandboxAreasRequest(areas=[SandboxArea(city="Москва")]), ) + tagged = sandbox.add_tags_to_sorting_center( + tariff_id="tf-1", + request=TaggedSortingCentersRequest( + items=[TaggedSortingCenter(delivery_provider_id="sc-1", direction_tag="moscow")] + ), + ) + updated_terms = sandbox.update_terms( + tariff_id="tf-1", + request=UpdateTermsRequest( + items=[ + DeliveryTermsZone( + delivery_provider_zone_id="zone-1", min_term=1, max_term=2, name="zone" + ) + ] + ), + ) + tariff = sandbox.add_tariff_v2( + request=AddTariffV2Request( + name="Tariff", + delivery_provider_tariff_id="tariff-1", + directions=[ + DeliveryDirection( + provider_direction_id="dir-1", + tag_from="moscow", + tag_to="spb", + zones=[ + DeliveryDirectionZone( + tariff_zone_id="tz-1", terms_zone_id="term-1", type="0" + ) + ], + ) + ], + tariff_zones=[ + DeliveryTariffZone( + name="Zone", + delivery_provider_zone_id="tz-1", + items=[ + DeliveryTariffItem( + calculation_mechanic="GAP_TO_COST", + chargeable_parameter="WEIGHT", + service_name="DELIVERY", + values=[DeliveryTariffValue(cost=10000, max_weight=3000)], + ) + ], + ) + ], + terms_zones=[ + DeliveryTermsZone( + delivery_provider_zone_id="term-1", min_term=1, max_term=2, name="term" + ) + ], + ) + ) + cancelled_announcement_v1 = sandbox.cancel_announcement_v1( + request=SandboxCancelAnnouncementRequest( + announcement_id="ann-1", + date="2026-04-20T10:00:00Z", + options=SandboxCancelAnnouncementOptions( + url_to_cancel_announcement="https://example.com/cancel" + ), + ) + ) + cancelled_parcel_v1 = sandbox.cancel_parcel_v1( + request=CancelSandboxParcelRequest(parcel_id="spar-1") + ) + changed_parcel_v1 = sandbox.change_parcel_v1( + request=ChangeParcelRequest(type="changeReceiver", parcel_id="spar-1") + ) + created_announcement_v1 = sandbox.create_announcement_v1( + request=SandboxCreateAnnouncementRequest( + announcement_id="ann-1", + barcode="barcode-1", + sender=SandboxAnnouncementParticipant( + type="3PL", + phones=["79990000000"], + email="sender@example.com", + name="Sender", + delivery=SandboxAnnouncementDelivery( + type="TERMINAL", + terminal=SandboxDeliveryPoint(provider="avito", point_id="term-1"), + ), + ), + receiver=SandboxAnnouncementParticipant( + type="ABD", + phones=["79990000001"], + email="receiver@example.com", + name="Receiver", + delivery=SandboxAnnouncementDelivery( + type="TERMINAL", + terminal=SandboxDeliveryPoint(provider="avito", point_id="term-2"), + ), + ), + announcement_type="DELIVERY", + date="2026-04-20T10:00:00Z", + packages=[SandboxAnnouncementPackage(package_id="pkg-1", parcel_ids=["spar-1"])], + options=SandboxCreateAnnouncementOptions( + url_to_send_announcement="https://example.com/announce" + ), + ) + ) + event_v1 = sandbox.get_announcement_event_v1( + request=SandboxGetAnnouncementEventRequest(announcement_id="ann-1") + ) + change_info_v1 = sandbox.get_change_parcel_info_v1( + request=GetChangeParcelInfoRequest(application_id="app-1") + ) + parcel_info_v1 = sandbox.get_parcel_info_v1( + request=GetSandboxParcelInfoRequest(parcel_id="spar-1") + ) + registered_parcel_id_v1 = sandbox.get_registered_parcel_id_v1( + request=GetRegisteredParcelIdRequest(order_id="sand-1") + ) sandbox_parcel = sandbox.create_parcel( request=DeliveryParcelRequest(order_id="sand-1", parcel_id="spar-1") ) @@ -251,8 +682,27 @@ def handler(request: httpx.Request) -> httpx.Response: assert changed.status == "parcels-updated" assert sandbox_announcement.task_id == "51" assert tracked.status == "tracked" + assert schedule.status == "schedule-updated" + assert cancelled_parcel.status == "parcel-cancelled" + assert sandbox_code_checked.status == "success" + assert props.status == "properties-set" + assert real_address.status == "real-address-set" + assert tracking.status == "tracking-saved" + assert prohibited.status == "acceptance-prohibited" assert centers.items[0].city == "Москва" + assert sorting_centers_added.task_id == "62" assert added_areas.status == "areas-added" + assert tagged.status == "tags-added" + assert updated_terms.status == "terms-updated" + assert tariff.task_id == "63" + assert cancelled_announcement_v1.status == "announcement-cancel-requested" + assert cancelled_parcel_v1.status == "sandbox-parcel-cancelled" + assert changed_parcel_v1.status == "change-created" + assert created_announcement_v1.status == "announcement-created-v1" + assert event_v1.status == "event-ready" + assert change_info_v1.status == "change-info-ready" + assert parcel_info_v1.status == "parcel-info-ready" + assert registered_parcel_id_v1.parcel_id == "reg-1" assert sandbox_parcel.parcel_id == "spar-1" assert task_info.status == "done" diff --git a/tests/test_stage8_jobs.py b/tests/test_stage8_jobs.py index 7259e26..c85735b 100644 --- a/tests/test_stage8_jobs.py +++ b/tests/test_stage8_jobs.py @@ -113,9 +113,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) applied = application.apply(request=ApplicationActionRequest(ids=["app-1"], action="invited")) current_hook = webhook.get() - updated_hook = webhook.update( - request=JobWebhookUpdateRequest(url="https://example.com/job") - ) + updated_hook = webhook.update(request=JobWebhookUpdateRequest(url="https://example.com/job")) deleted_hook = webhook.delete(url="https://example.com/job") hooks = webhook.list() diff --git a/tests/test_stage8_serialization_contract.py b/tests/test_stage8_serialization_contract.py index 75e4c75..589c9fd 100644 --- a/tests/test_stage8_serialization_contract.py +++ b/tests/test_stage8_serialization_contract.py @@ -61,10 +61,8 @@ def test_recursive_serialization_is_json_compatible_and_hides_transport_fields() price=1990, original_price=2490, packages_count=2, - _payload={"transport": True}, ), scheduled=None, - _payload={"transport": True}, ) catalog = CatalogResolveResult( items=[ @@ -76,13 +74,10 @@ def test_recursive_serialization_is_json_compatible_and_hides_transport_fields() CatalogFieldValue( value_id="1", label="Audi", - _payload={"transport": True}, ) ], - _payload={"transport": True}, ) ], - _payload={"transport": True}, ) request = SendMessageRequest(message="hello") diff --git a/tests/test_stage9_transport_isolation.py b/tests/test_stage9_transport_isolation.py index 33f967b..d07e004 100644 --- a/tests/test_stage9_transport_isolation.py +++ b/tests/test_stage9_transport_isolation.py @@ -142,7 +142,9 @@ def handler(request: httpx.Request) -> httpx.Response: def test_mappers_keep_stable_contract_for_happy_and_partial_payloads() -> None: - happy_profile = map_account_profile({"id": 7, "name": "Main shop", "email": "shop@example.test"}) + happy_profile = map_account_profile( + {"id": 7, "name": "Main shop", "email": "shop@example.test"} + ) partial_profile = map_account_profile({"user_id": 7, "title": "Main shop"}) happy_listing = map_ad_item( From 80473919afed48393c91fbb8e8a56ebe16de94df Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Sun, 19 Apr 2026 16:36:07 +0300 Subject: [PATCH 04/17] =?UTF-8?q?=D0=98=D0=B4=D1=83=20=D0=BA=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B3=D0=BE=D0=BC=D1=83=20=D0=BE=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B0=D0=BD=D0=B8=D1=8E=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 + README.md | 36 ++- avito/ads/__init__.py | 4 +- avito/ads/client.py | 60 ++--- avito/ads/domain.py | 58 +++-- avito/auth/__init__.py | 4 +- avito/auth/provider.py | 34 +-- avito/auth/settings.py | 16 +- avito/client/client.py | 32 ++- avito/cpa/__init__.py | 4 +- avito/cpa/client.py | 30 +-- avito/cpa/domain.py | 28 +-- avito/jobs/client.py | 44 ++-- avito/jobs/domain.py | 22 +- avito/orders/client.py | 42 ++-- avito/orders/domain.py | 40 ++-- avito/promotion/__init__.py | 18 ++ avito/promotion/client.py | 89 ++----- avito/promotion/domain.py | 64 ++--- avito/promotion/mappers.py | 115 +++++++-- avito/promotion/models.py | 218 ++++++++++++++++-- avito/ratings/client.py | 8 +- avito/ratings/domain.py | 16 +- avito/realty/client.py | 2 +- avito/realty/domain.py | 4 +- tests/test_auth.py | 14 +- tests/test_facade.py | 22 +- tests/test_public_api_shape.py | 78 +++++++ tests/test_readme_examples.py | 63 +++++ tests/test_stage11_mock_transport_suite.py | 4 +- tests/test_stage11_realty_ratings_tariffs.py | 10 +- tests/test_stage12_release_gate.py | 10 +- tests/test_stage4_accounts_ads.py | 16 +- tests/test_stage4_promotion_write_contract.py | 6 +- tests/test_stage6_promotion.py | 135 ++++++++--- tests/test_stage7_orders.py | 18 +- tests/test_stage9_cpa.py | 14 +- 37 files changed, 925 insertions(+), 457 deletions(-) create mode 100644 tests/test_readme_examples.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a8605c..6709939 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,3 +30,7 @@ - `jobs` больше не использует generic `JobsRequest` / `JobsQuery`: публичный surface переведен на отдельные typed request/query-модели для applications, vacancies, resumes и webhooks. - `autoteka` больше не использует generic `AutotekaRequest` / `AutotekaQuery`: публичный surface переведен на отдельные typed request/query-модели для preview, report, monitoring, scoring и valuation сценариев. - `messenger.ChatMedia.upload_images()` больше не принимает `dict[str, object]`; вместо него используется typed request через `UploadImageFile` / `UploadImagesRequest`. +- `promotion.autostrategy` больше не использует generic payload-wrapper’ы: `CreateAutostrategyBudgetRequest`, `CreateAutostrategyCampaignRequest`, `UpdateAutostrategyCampaignRequest` и `ListAutostrategyCampaignsRequest` теперь содержат typed поля по documented contract. +- `promotion` и `ads` write-клиенты больше не раскрывают `Mapping[str, object]` в публичных сигнатурах helper/client-слоя; preview и apply используют одинаковый typed request contract. +- Автостратегия приведена к documented shape ответов: бюджет теперь возвращает `calc_id`, список кампаний включает `total_count`, `CampaignDetailsResult` хранит `campaign` / `forecast` / `items`, а `AutostrategyStat` содержит ежедневные значения и `totals`. +- Публичный surface очищен от неканоничных имен: `autoload_legacy()` -> `autoload_archive()`, `cpa_legacy()` -> `cpa_archive()`, `apply_vas_v2()` -> `apply_vas_direct()`, version-suffixed методы ratings/realty/orders заменены на каноничные имена без `_v1` / `_v2`. diff --git a/README.md b/README.md index ffe7620..a0666df 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ - дать единый вход в доменные сценарии вида `avito.ad(...).get()` и `avito.chat(...).send_message(...)`; - покрыть все swagger-документы из каталога [docs](docs). +Каталог [docs](docs) рассматривается как upstream API contract. Эти файлы не редактируются вручную при развитии SDK: публичные модели, мапперы и тесты должны подстраиваться под documented shape из `docs/*`. + ## Установка ```bash @@ -138,11 +140,43 @@ with AvitoClient() as avito: ```python from avito import AvitoClient +from avito.promotion.models import ( + CampaignListFilter, + CampaignOrderBy, + CampaignUpdateTimeFilter, + CreateAutostrategyBudgetRequest, + ListAutostrategyCampaignsRequest, +) with AvitoClient() as avito: services = avito.promotion_order().list_orders() forecast = avito.bbip_promotion(item_id=42).get_forecasts(items=[]) + budget = avito.autostrategy_campaign().create_budget( + request=CreateAutostrategyBudgetRequest( + campaign_type="AS", + start_time="2026-04-20T00:00:00Z", + finish_time="2026-04-27T00:00:00Z", + items=[42, 43], + ) + ) campaign = avito.autostrategy_campaign(campaign_id=15).get() + campaigns = avito.autostrategy_campaign().list( + request=ListAutostrategyCampaignsRequest( + limit=50, + status_id=[1, 2], + order_by=[CampaignOrderBy(column="startTime", direction="asc")], + filter=CampaignListFilter( + by_update_time=CampaignUpdateTimeFilter( + from_time="2026-04-01T00:00:00Z", + to_time="2026-04-30T00:00:00Z", + ) + ), + ) + ) + +print(budget.calc_id) +print(campaign.campaign.title if campaign.campaign else None) +print(campaigns.total_count) ``` ### Заказы и доставка @@ -237,7 +271,7 @@ with AvitoClient() as avito: periods=[RealtyPricePeriod(date_from="2026-05-01", price=5000)] ) ) - reviews = avito.review().list_reviews_v1() + reviews = avito.review().list() tariff = avito.tariff().get_tariff_info() ``` diff --git a/avito/ads/__init__.py b/avito/ads/__init__.py index 74d33db..74722ac 100644 --- a/avito/ads/__init__.py +++ b/avito/ads/__init__.py @@ -4,7 +4,7 @@ Ad, AdPromotion, AdStats, - AutoloadLegacy, + AutoloadArchive, AutoloadProfile, AutoloadReport, DomainObject, @@ -53,7 +53,7 @@ "AutoloadFeesResult", "AutoloadField", "AutoloadFieldsResult", - "AutoloadLegacy", + "AutoloadArchive", "AutoloadProfile", "AutoloadProfileSettings", "AutoloadReport", diff --git a/avito/ads/client.py b/avito/ads/client.py index 256827c..4db9ff0 100644 --- a/avito/ads/client.py +++ b/avito/ads/client.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass from avito.ads.mappers import ( @@ -256,15 +255,10 @@ def apply_item_vas( user_id: int, item_id: int, request: ApplyVasRequest, - action: str = "apply_vas", - target: Mapping[str, object] | None = None, - request_payload: Mapping[str, object] | None = None, ) -> PromotionActionResult: """Применяет дополнительные услуги к объявлению.""" - payload_to_send = ( - dict(request_payload) if request_payload is not None else request.to_payload() - ) + payload_to_send = request.to_payload() payload = self.transport.request_json( "PUT", f"/core/v1/accounts/{user_id}/items/{item_id}/vas", @@ -273,8 +267,8 @@ def apply_item_vas( ) return map_promotion_action( payload, - action=action, - target=target or {"item_id": item_id, "user_id": user_id}, + action="apply_vas", + target={"item_id": item_id, "user_id": user_id}, request_payload=payload_to_send, ) @@ -284,15 +278,10 @@ def apply_item_vas_package( user_id: int, item_id: int, request: ApplyVasPackageRequest, - action: str = "apply_vas_package", - target: Mapping[str, object] | None = None, - request_payload: Mapping[str, object] | None = None, ) -> PromotionActionResult: """Применяет пакет дополнительных услуг.""" - payload_to_send = ( - dict(request_payload) if request_payload is not None else request.to_payload() - ) + payload_to_send = request.to_payload() payload = self.transport.request_json( "PUT", f"/core/v2/accounts/{user_id}/items/{item_id}/vas_packages", @@ -301,35 +290,30 @@ def apply_item_vas_package( ) return map_promotion_action( payload, - action=action, - target=target or {"item_id": item_id, "user_id": user_id}, + action="apply_vas_package", + target={"item_id": item_id, "user_id": user_id}, request_payload=payload_to_send, ) - def apply_vas_v2( + def apply_vas_direct( self, *, item_id: int, request: ApplyVasRequest, - action: str = "apply_vas_v2", - target: Mapping[str, object] | None = None, - request_payload: Mapping[str, object] | None = None, ) -> PromotionActionResult: """Применяет услуги продвижения через v2 endpoint.""" - payload_to_send = ( - dict(request_payload) if request_payload is not None else request.to_payload() - ) + payload_to_send = request.to_payload() payload = self.transport.request_json( "PUT", f"/core/v2/items/{item_id}/vas/", - context=RequestContext("ads.vas.apply_v2", allow_retry=True), + context=RequestContext("ads.vas.apply_direct", allow_retry=True), json_body=payload_to_send, ) return map_promotion_action( payload, - action=action, - target=target or {"item_id": item_id}, + action="apply_vas_direct", + target={"item_id": item_id}, request_payload=payload_to_send, ) @@ -493,55 +477,55 @@ def get_report_fees(self, *, report_id: int) -> AutoloadFeesResult: @dataclass(slots=True) -class AutoloadLegacyClient: - """Выполняет legacy HTTP-операции автозагрузки.""" +class AutoloadArchiveClient: + """Выполняет архивные HTTP-операции автозагрузки.""" transport: Transport def get_profile(self) -> AutoloadProfileSettings: - """Получает legacy профиль автозагрузки.""" + """Получает архивный профиль автозагрузки.""" return request_public_model( self.transport, "GET", "/autoload/v1/profile", - context=RequestContext("ads.autoload_legacy.get_profile"), + context=RequestContext("ads.autoload_archive.get_profile"), mapper=map_autoload_profile, ) def save_profile(self, request: AutoloadProfileUpdateRequest) -> ActionResult: - """Создает или редактирует legacy профиль автозагрузки.""" + """Создает или редактирует архивный профиль автозагрузки.""" return request_public_model( self.transport, "POST", "/autoload/v1/profile", - context=RequestContext("ads.autoload_legacy.save_profile", allow_retry=True), + context=RequestContext("ads.autoload_archive.save_profile", allow_retry=True), mapper=map_action_result, json_body=request.to_payload(), ) def get_last_completed_report(self) -> LegacyAutoloadReport: - """Получает статистику по последней выгрузке legacy v2.""" + """Получает статистику по последней архивной выгрузке.""" return request_public_model( self.transport, "GET", "/autoload/v2/reports/last_completed_report", - context=RequestContext("ads.autoload_legacy.get_last_completed_report"), + context=RequestContext("ads.autoload_archive.get_last_completed_report"), mapper=map_legacy_autoload_report, ) def get_report(self, *, report_id: int) -> LegacyAutoloadReport: - """Получает статистику по конкретной выгрузке legacy v2.""" + """Получает статистику по конкретной архивной выгрузке.""" return request_public_model( self.transport, "GET", f"/autoload/v2/reports/{report_id}", - context=RequestContext("ads.autoload_legacy.get_report"), + context=RequestContext("ads.autoload_archive.get_report"), mapper=map_legacy_autoload_report, ) -__all__ = ("AdsClient", "AutoloadClient", "AutoloadLegacyClient", "StatsClient", "VasClient") +__all__ = ("AdsClient", "AutoloadArchiveClient", "AutoloadClient", "StatsClient", "VasClient") diff --git a/avito/ads/domain.py b/avito/ads/domain.py index 4e3c838..0cec4ee 100644 --- a/avito/ads/domain.py +++ b/avito/ads/domain.py @@ -2,10 +2,17 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Sequence from dataclasses import dataclass - -from avito.ads.client import AdsClient, AutoloadClient, AutoloadLegacyClient, StatsClient, VasClient +from typing import Any + +from avito.ads.client import ( + AdsClient, + AutoloadArchiveClient, + AutoloadClient, + StatsClient, + VasClient, +) from avito.ads.models import ( ActionResult, AdItem, @@ -39,7 +46,7 @@ from avito.promotion.models import PromotionActionResult -def _validate_non_empty_items(name: str, items: Sequence[object]) -> None: +def _validate_non_empty_items(name: str, items: Sequence[Any]) -> None: if not items: raise ValidationError(f"`{name}` must contain at least one item.") @@ -58,8 +65,8 @@ def _validate_string_items(name: str, values: Sequence[str]) -> None: def _preview_result( *, action: str, - target: Mapping[str, object], - request_payload: Mapping[str, object], + target: dict[str, Any], + request_payload: dict[str, Any], ) -> PromotionActionResult: return PromotionActionResult( action=action, @@ -286,9 +293,6 @@ def apply_vas( user_id=user_id, item_id=item_id, request=request, - action="apply_vas", - target=target, - request_payload=request_payload, ) def apply_vas_package( @@ -314,18 +318,15 @@ def apply_vas_package( user_id=user_id, item_id=item_id, request=request, - action="apply_vas_package", - target=target, - request_payload=request_payload, ) - def apply_vas_v2( + def apply_vas_direct( self, *, codes: list[str], dry_run: bool = False, ) -> PromotionActionResult: - """Применяет услуги продвижения через v2 endpoint.""" + """Применяет услуги продвижения через прямой v2 endpoint.""" item_id = self._require_item_id() _validate_string_items("codes", codes) @@ -334,16 +335,13 @@ def apply_vas_v2( target = {"item_id": item_id} if dry_run: return _preview_result( - action="apply_vas_v2", + action="apply_vas_direct", target=target, request_payload=request_payload, ) - return VasClient(self.transport).apply_vas_v2( + return VasClient(self.transport).apply_vas_direct( item_id=item_id, request=request, - action="apply_vas_v2", - target=target, - request_payload=request_payload, ) def _require_item_id(self) -> int: @@ -460,16 +458,16 @@ def _require_report_id(self) -> int: @dataclass(slots=True, frozen=True) -class AutoloadLegacy(DomainObject): - """Доменный объект legacy-операций автозагрузки.""" +class AutoloadArchive(DomainObject): + """Доменный объект архивных операций автозагрузки.""" resource_id: int | str | None = None user_id: int | str | None = None def get_profile(self) -> AutoloadProfileSettings: - """Получает legacy профиль автозагрузки.""" + """Получает архивный профиль автозагрузки.""" - return AutoloadLegacyClient(self.transport).get_profile() + return AutoloadArchiveClient(self.transport).get_profile() def save_profile( self, @@ -478,24 +476,24 @@ def save_profile( email: str | None = None, callback_url: str | None = None, ) -> ActionResult: - """Сохраняет legacy профиль автозагрузки.""" + """Сохраняет архивный профиль автозагрузки.""" - return AutoloadLegacyClient(self.transport).save_profile( + return AutoloadArchiveClient(self.transport).save_profile( AutoloadProfileUpdateRequest( is_enabled=is_enabled, email=email, callback_url=callback_url ) ) def get_last_completed_report(self) -> LegacyAutoloadReport: - """Получает legacy статистику по последней выгрузке.""" + """Получает архивную статистику по последней выгрузке.""" - return AutoloadLegacyClient(self.transport).get_last_completed_report() + return AutoloadArchiveClient(self.transport).get_last_completed_report() def get_report(self) -> LegacyAutoloadReport: - """Получает legacy статистику по конкретной выгрузке.""" + """Получает архивную статистику по конкретной выгрузке.""" report_id = self._require_report_id() - return AutoloadLegacyClient(self.transport).get_report(report_id=report_id) + return AutoloadArchiveClient(self.transport).get_report(report_id=report_id) def _require_report_id(self) -> int: if self.resource_id is None: @@ -510,5 +508,5 @@ def _require_report_id(self) -> int: "AdPromotion", "AutoloadProfile", "AutoloadReport", - "AutoloadLegacy", + "AutoloadArchive", ) diff --git a/avito/auth/__init__.py b/avito/auth/__init__.py index f5c1f53..df3f76d 100644 --- a/avito/auth/__init__.py +++ b/avito/auth/__init__.py @@ -6,15 +6,15 @@ RefreshTokenRequest, TokenResponse, ) -from avito.auth.provider import AuthProvider, LegacyTokenClient, TokenClient +from avito.auth.provider import AlternateTokenClient, AuthProvider, TokenClient from avito.auth.settings import AuthSettings __all__ = ( "AccessToken", + "AlternateTokenClient", "AuthProvider", "AuthSettings", "ClientCredentialsRequest", - "LegacyTokenClient", "RefreshTokenRequest", "TokenClient", "TokenResponse", diff --git a/avito/auth/provider.py b/avito/auth/provider.py index b08b866..1881985 100644 --- a/avito/auth/provider.py +++ b/avito/auth/provider.py @@ -35,7 +35,7 @@ class AuthProvider: settings: AuthSettings token_client: TokenClient | None = None - legacy_token_client: LegacyTokenClient | None = None + 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) @@ -66,7 +66,7 @@ def invalidate_token(self) -> None: def close(self) -> None: """Закрывает внутренние HTTP-клиенты provider-а.""" - for client in (self.token_client, self.legacy_token_client, self.autoteka_token_client): + for client in (self.token_client, self.alternate_token_client, self.autoteka_token_client): if client is not None: client.close() @@ -94,10 +94,10 @@ def token_flow(self) -> TokenClient: return self._get_token_client() - def legacy_token_flow(self) -> LegacyTokenClient: - """Возвращает legacy alias token client без отдельного публичного API метода.""" + def alternate_token_flow(self) -> AlternateTokenClient: + """Возвращает дополнительный token client для альтернативного `/token` flow.""" - return self._get_legacy_token_client() + return self._get_alternate_token_client() def _fetch_token_response(self) -> TokenResponse: if self.token_fetcher is not None: @@ -140,10 +140,10 @@ def _get_token_client(self) -> TokenClient: self.token_client = TokenClient(self.settings) return self.token_client - def _get_legacy_token_client(self) -> LegacyTokenClient: - if self.legacy_token_client is None: - self.legacy_token_client = LegacyTokenClient(self.settings) - return self.legacy_token_client + def _get_alternate_token_client(self) -> AlternateTokenClient: + if self.alternate_token_client is None: + self.alternate_token_client = AlternateTokenClient(self.settings) + return self.alternate_token_client def _get_autoteka_token_client(self) -> TokenClient: if self.autoteka_token_client is None: @@ -265,14 +265,14 @@ def _extract_error_code(self, response: httpx.Response) -> str | None: @dataclass(slots=True) -class LegacyTokenClient: - """Служебный клиент для legacy alias token endpoint из swagger.""" +class AlternateTokenClient: + """Служебный клиент для альтернативного token endpoint из swagger.""" settings: AuthSettings client: httpx.Client | None = None def close(self) -> None: - """Закрывает выделенный HTTP-клиент legacy token flow.""" + """Закрывает выделенный HTTP-клиент альтернативного token flow.""" if self.client is not None: self.client.close() @@ -281,22 +281,22 @@ def request_client_credentials_token( self, request: ClientCredentialsRequest, ) -> TokenResponse: - """Запрашивает токен через legacy alias canonical `/token`.""" + """Запрашивает токен через альтернативный canonical `/token`.""" return TokenClient( self.settings, - token_url=self.settings.legacy_token_url, + token_url=self.settings.alternate_token_url, client=self.client, ).request_client_credentials_token(request) def request_refresh_token(self, request: RefreshTokenRequest) -> TokenResponse: - """Обновляет токен через legacy alias canonical `/token`.""" + """Обновляет токен через альтернативный canonical `/token`.""" return TokenClient( self.settings, - token_url=self.settings.legacy_token_url, + token_url=self.settings.alternate_token_url, client=self.client, ).request_refresh_token(request) -__all__ = ("AuthProvider", "LegacyTokenClient", "TokenClient", "TokenFetcher") +__all__ = ("AlternateTokenClient", "AuthProvider", "TokenClient", "TokenFetcher") diff --git a/avito/auth/settings.py b/avito/auth/settings.py index dae817a..38747a8 100644 --- a/avito/auth/settings.py +++ b/avito/auth/settings.py @@ -30,10 +30,10 @@ class AuthSettings(BaseModel): "REFRESH_TOKEN", ), "token_url": ("AVITO_AUTH__TOKEN_URL", "AVITO_TOKEN_URL", "TOKEN_URL"), - "legacy_token_url": ( - "AVITO_AUTH__LEGACY_TOKEN_URL", - "AVITO_LEGACY_TOKEN_URL", - "LEGACY_TOKEN_URL", + "alternate_token_url": ( + "AVITO_AUTH__ALTERNATE_TOKEN_URL", + "AVITO_ALTERNATE_TOKEN_URL", + "ALTERNATE_TOKEN_URL", ), "autoteka_token_url": ( "AVITO_AUTH__AUTOTEKA_TOKEN_URL", @@ -90,12 +90,12 @@ class AuthSettings(BaseModel): default="/token", validation_alias=AliasChoices("AVITO_AUTH__TOKEN_URL", "AVITO_TOKEN_URL", "TOKEN_URL"), ) - legacy_token_url: str = Field( + alternate_token_url: str = Field( default="/token", validation_alias=AliasChoices( - "AVITO_AUTH__LEGACY_TOKEN_URL", - "AVITO_LEGACY_TOKEN_URL", - "LEGACY_TOKEN_URL", + "AVITO_AUTH__ALTERNATE_TOKEN_URL", + "AVITO_ALTERNATE_TOKEN_URL", + "ALTERNATE_TOKEN_URL", ), ) autoteka_token_url: str = Field( diff --git a/avito/client/client.py b/avito/client/client.py index 97746bb..7b9887f 100644 --- a/avito/client/client.py +++ b/avito/client/client.py @@ -3,12 +3,13 @@ from __future__ import annotations from pathlib import Path +from types import TracebackType import httpx from avito.accounts import Account, AccountHierarchy -from avito.ads import Ad, AdPromotion, AdStats, AutoloadLegacy, AutoloadProfile, AutoloadReport -from avito.auth import AuthProvider, LegacyTokenClient, TokenClient +from avito.ads import Ad, AdPromotion, AdStats, AutoloadArchive, AutoloadProfile, AutoloadReport +from avito.auth import AlternateTokenClient, AuthProvider, TokenClient from avito.autoteka import ( AutotekaMonitoring, AutotekaReport, @@ -18,7 +19,7 @@ ) from avito.config import AvitoSettings from avito.core import Transport, TransportDebugInfo -from avito.cpa import CallTrackingCall, CpaCall, CpaChat, CpaLead, CpaLegacy +from avito.cpa import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead from avito.jobs import Application, JobDictionary, JobWebhook, Resume, Vacancy from avito.messenger import Chat, ChatMedia, ChatMessage, ChatWebhook, SpecialOfferCampaign from avito.orders import DeliveryOrder, DeliveryTask, Order, OrderLabel, SandboxDelivery, Stock @@ -80,19 +81,26 @@ def __enter__(self) -> AvitoClient: return self - def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: """Закрывает клиент при выходе из контекстного менеджера.""" self.close() def _build_auth_provider(self) -> AuthProvider: token_http_client = httpx.Client(base_url=self.settings.base_url.rstrip("/")) - legacy_http_client = httpx.Client(base_url=self.settings.base_url.rstrip("/")) + alternate_http_client = httpx.Client(base_url=self.settings.base_url.rstrip("/")) autoteka_http_client = httpx.Client(base_url=self.settings.base_url.rstrip("/")) return AuthProvider( self.settings.auth, token_client=TokenClient(self.settings.auth, client=token_http_client), - legacy_token_client=LegacyTokenClient(self.settings.auth, client=legacy_http_client), + alternate_token_client=AlternateTokenClient( + self.settings.auth, client=alternate_http_client + ), autoteka_token_client=TokenClient( self.settings.auth, token_url=self.settings.auth.autoteka_token_url, @@ -139,10 +147,10 @@ def autoload_report(self, report_id: int | str | None = None) -> AutoloadReport: return AutoloadReport(self.transport, resource_id=report_id) - def autoload_legacy(self, report_id: int | str | None = None) -> AutoloadLegacy: - """Создает доменный объект legacy-операций автозагрузки.""" + def autoload_archive(self, report_id: int | str | None = None) -> AutoloadArchive: + """Создает доменный объект архивных операций автозагрузки.""" - return AutoloadLegacy(self.transport, resource_id=report_id) + return AutoloadArchive(self.transport, resource_id=report_id) def chat(self, chat_id: int | str | None = None, *, user_id: int | str | None = None) -> Chat: """Создает доменный объект чата.""" @@ -278,10 +286,10 @@ def cpa_call(self, call_id: int | str | None = None) -> CpaCall: return CpaCall(self.transport, resource_id=call_id) - def cpa_legacy(self, legacy_id: int | str | None = None) -> CpaLegacy: - """Создает доменный объект legacy-операций CPA.""" + def cpa_archive(self, call_id: int | str | None = None) -> CpaArchive: + """Создает доменный объект архивных операций CPA.""" - return CpaLegacy(self.transport, resource_id=legacy_id) + return CpaArchive(self.transport, resource_id=call_id) def call_tracking_call(self, call_id: int | str | None = None) -> CallTrackingCall: """Создает доменный объект CallTracking.""" diff --git a/avito/cpa/__init__.py b/avito/cpa/__init__.py index 616cc96..11bf490 100644 --- a/avito/cpa/__init__.py +++ b/avito/cpa/__init__.py @@ -1,6 +1,6 @@ """Пакет cpa.""" -from avito.cpa.domain import CallTrackingCall, CpaCall, CpaChat, CpaLead, CpaLegacy, DomainObject +from avito.cpa.domain import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead, DomainObject from avito.cpa.models import ( CallTrackingCallInfo, CallTrackingCallResponse, @@ -37,6 +37,7 @@ "CpaActionResult", "CpaAudioRecord", "CpaBalanceInfo", + "CpaArchive", "CpaCall", "CpaCallByIdRequest", "CpaCallComplaintRequest", @@ -50,7 +51,6 @@ "CpaErrorInfo", "CpaLead", "CpaLeadComplaintRequest", - "CpaLegacy", "CpaPhoneInfo", "CpaPhonesFromChatsRequest", "CpaPhonesResult", diff --git a/avito/cpa/client.py b/avito/cpa/client.py index a3994b7..2df360c 100644 --- a/avito/cpa/client.py +++ b/avito/cpa/client.py @@ -55,22 +55,22 @@ def get_by_action_id(self, *, action_id: int | str) -> CpaChatInfo: mapper=map_chat_item, ) - def list_by_time_v1(self, request: CpaChatsByTimeRequest) -> CpaChatsResult: + def list_by_time_classic(self, request: CpaChatsByTimeRequest) -> CpaChatsResult: return request_public_model( self.transport, "POST", "/cpa/v1/chatsByTime", - context=RequestContext("cpa.chats.list_by_time_v1", allow_retry=True), + context=RequestContext("cpa.chats.list_by_time_classic", allow_retry=True), mapper=map_chats, json_body=request.to_payload(), ) - def list_by_time_v2(self, request: CpaChatsByTimeRequest) -> CpaChatsResult: + def list_by_time(self, request: CpaChatsByTimeRequest) -> CpaChatsResult: return request_public_model( self.transport, "POST", "/cpa/v2/chatsByTime", - context=RequestContext("cpa.chats.list_by_time_v2", allow_retry=True), + context=RequestContext("cpa.chats.list_by_time", allow_retry=True), mapper=map_chats, json_body=request.to_payload(), ) @@ -92,12 +92,12 @@ class CpaCallsClient: transport: Transport - def list_by_time_v2(self, request: CpaCallsByTimeRequest) -> CpaCallsResult: + def list_by_time(self, request: CpaCallsByTimeRequest) -> CpaCallsResult: return request_public_model( self.transport, "POST", "/cpa/v2/callsByTime", - context=RequestContext("cpa.calls.list_by_time_v2", allow_retry=True), + context=RequestContext("cpa.calls.list_by_time", allow_retry=True), mapper=map_calls, json_body=request.to_payload(), ) @@ -129,46 +129,46 @@ def create_complaint_by_action_id(self, request: CpaLeadComplaintRequest) -> Cpa json_body=request.to_payload(), ) - def get_balance_info_v3(self) -> CpaBalanceInfo: + def get_balance_info(self) -> CpaBalanceInfo: return request_public_model( self.transport, "POST", "/cpa/v3/balanceInfo", - context=RequestContext("cpa.leads.get_balance_info_v3", allow_retry=True), + context=RequestContext("cpa.leads.get_balance_info", allow_retry=True), mapper=map_balance, json_body={}, ) @dataclass(slots=True) -class CpaLegacyClient: - """Выполняет legacy HTTP-операции CPA.""" +class CpaArchiveClient: + """Выполняет архивные HTTP-операции CPA.""" transport: Transport def get_record(self, *, call_id: int | str) -> CpaAudioRecord: binary = self.transport.download_binary( f"/cpa/v1/call/{call_id}", - context=RequestContext("cpa.legacy.get_record"), + context=RequestContext("cpa.archive.get_record"), ) return CpaAudioRecord(binary) - def get_balance_info_v2(self) -> CpaBalanceInfo: + def get_balance_info(self) -> CpaBalanceInfo: return request_public_model( self.transport, "POST", "/cpa/v2/balanceInfo", - context=RequestContext("cpa.legacy.get_balance_info_v2", allow_retry=True), + context=RequestContext("cpa.archive.get_balance_info", allow_retry=True), mapper=map_balance, json_body={}, ) - def get_call_by_id_v2(self, request: CpaCallByIdRequest) -> CpaCallInfo: + def get_call_by_id(self, request: CpaCallByIdRequest) -> CpaCallInfo: return request_public_model( self.transport, "POST", "/cpa/v2/callById", - context=RequestContext("cpa.legacy.get_call_by_id_v2", allow_retry=True), + context=RequestContext("cpa.archive.get_call_by_id", allow_retry=True), mapper=map_call_item, json_body=request.to_payload(), ) diff --git a/avito/cpa/domain.py b/avito/cpa/domain.py index f2c9475..e0be020 100644 --- a/avito/cpa/domain.py +++ b/avito/cpa/domain.py @@ -7,10 +7,10 @@ from avito.core import Transport, ValidationError from avito.cpa.client import ( CallTrackingClient, + CpaArchiveClient, CpaCallsClient, CpaChatsClient, CpaLeadsClient, - CpaLegacyClient, ) from avito.cpa.models import ( CallTrackingCallResponse, @@ -56,8 +56,8 @@ def create_complaint_by_action_id( ) -> CpaActionResult: return CpaLeadsClient(self.transport).create_complaint_by_action_id(request) - def create_balance_info_v3(self) -> CpaBalanceInfo: - return CpaLeadsClient(self.transport).get_balance_info_v3() + def get_balance_info(self) -> CpaBalanceInfo: + return CpaLeadsClient(self.transport).get_balance_info() @dataclass(slots=True, frozen=True) @@ -80,8 +80,8 @@ def list( ) -> CpaChatsResult: client = CpaChatsClient(self.transport) if version == 1: - return client.list_by_time_v1(request) - return client.list_by_time_v2(request) + return client.list_by_time_classic(request) + return client.list_by_time(request) def get_phones_info_from_chats( self, @@ -104,29 +104,29 @@ class CpaCall(DomainObject): user_id: int | str | None = None def list(self, *, request: CpaCallsByTimeRequest) -> CpaCallsResult: - return CpaCallsClient(self.transport).list_by_time_v2(request) + return CpaCallsClient(self.transport).list_by_time(request) def create_complaint(self, *, request: CpaCallComplaintRequest) -> CpaActionResult: return CpaCallsClient(self.transport).create_complaint(request) @dataclass(slots=True, frozen=True) -class CpaLegacy(DomainObject): - """Доменный объект legacy-операций CPA.""" +class CpaArchive(DomainObject): + """Доменный объект архивных операций CPA.""" resource_id: int | str | None = None user_id: int | str | None = None def get_call(self, *, call_id: int | str | None = None) -> CpaAudioRecord: - return CpaLegacyClient(self.transport).get_record( + return CpaArchiveClient(self.transport).get_record( call_id=call_id or self._require_resource_id() ) - def get_balance_info_v2(self) -> CpaBalanceInfo: - return CpaLegacyClient(self.transport).get_balance_info_v2() + def get_balance_info(self) -> CpaBalanceInfo: + return CpaArchiveClient(self.transport).get_balance_info() - def get_call_by_id_v2(self, *, request: CpaCallByIdRequest) -> CpaCallInfo: - return CpaLegacyClient(self.transport).get_call_by_id_v2(request) + def get_call_by_id(self, *, request: CpaCallByIdRequest) -> CpaCallInfo: + return CpaArchiveClient(self.transport).get_call_by_id(request) def _require_resource_id(self) -> str: if self.resource_id is None: @@ -165,4 +165,4 @@ def _require_resource_id(self) -> str: return str(self.resource_id) -__all__ = ("CallTrackingCall", "CpaCall", "CpaChat", "CpaLead", "CpaLegacy", "DomainObject") +__all__ = ("CallTrackingCall", "CpaArchive", "CpaCall", "CpaChat", "CpaLead", "DomainObject") diff --git a/avito/jobs/client.py b/avito/jobs/client.py index d220bd7..e8fdba5 100644 --- a/avito/jobs/client.py +++ b/avito/jobs/client.py @@ -200,17 +200,17 @@ class VacanciesClient: transport: Transport - def create_v1(self, request: VacancyCreateRequest) -> JobActionResult: + def create_classic(self, request: VacancyCreateRequest) -> JobActionResult: return request_public_model( self.transport, "POST", "/job/v1/vacancies", - context=RequestContext("jobs.vacancies.create_v1", allow_retry=True), + context=RequestContext("jobs.vacancies.create_classic", allow_retry=True), mapper=map_job_action, json_body=request.to_payload(), ) - def archive_v1( + def archive( self, *, vacancy_id: int | str, @@ -220,12 +220,12 @@ def archive_v1( self.transport, "PUT", f"/job/v1/vacancies/archived/{vacancy_id}", - context=RequestContext("jobs.vacancies.archive_v1", allow_retry=True), + context=RequestContext("jobs.vacancies.archive", allow_retry=True), mapper=map_job_action, json_body=request.to_payload(), ) - def update_v1( + def update_classic( self, *, vacancy_id: int | str, @@ -235,12 +235,12 @@ def update_v1( self.transport, "PUT", f"/job/v1/vacancies/{vacancy_id}", - context=RequestContext("jobs.vacancies.update_v1", allow_retry=True), + context=RequestContext("jobs.vacancies.update_classic", allow_retry=True), mapper=map_job_action, json_body=request.to_payload(), ) - def prolongate_v1( + def prolongate( self, *, vacancy_id: int | str, @@ -250,74 +250,74 @@ def prolongate_v1( self.transport, "POST", f"/job/v1/vacancies/{vacancy_id}/prolongate", - context=RequestContext("jobs.vacancies.prolongate_v1", allow_retry=True), + context=RequestContext("jobs.vacancies.prolongate", allow_retry=True), mapper=map_job_action, json_body=request.to_payload(), ) - def list_v2(self, *, query: VacanciesQuery | None = None) -> VacanciesResult: + def list(self, *, query: VacanciesQuery | None = None) -> VacanciesResult: return request_public_model( self.transport, "GET", "/job/v2/vacancies", - context=RequestContext("jobs.vacancies.list_v2"), + context=RequestContext("jobs.vacancies.list"), mapper=map_vacancies, params=query.to_params() if query is not None else None, ) - def create_v2(self, request: VacancyCreateRequest) -> JobActionResult: + def create(self, request: VacancyCreateRequest) -> JobActionResult: return request_public_model( self.transport, "POST", "/job/v2/vacancies", - context=RequestContext("jobs.vacancies.create_v2", allow_retry=True), + context=RequestContext("jobs.vacancies.create", allow_retry=True), mapper=map_job_action, json_body=request.to_payload(), ) - def get_by_ids_v2(self, request: VacancyIdsRequest) -> VacanciesResult: + def get_by_ids(self, request: VacancyIdsRequest) -> VacanciesResult: return request_public_model( self.transport, "POST", "/job/v2/vacancies/batch", - context=RequestContext("jobs.vacancies.get_by_ids_v2", allow_retry=True), + context=RequestContext("jobs.vacancies.get_by_ids", allow_retry=True), mapper=map_vacancies, json_body=request.to_payload(), ) - def get_statuses_v2(self, request: VacancyIdsRequest) -> VacancyStatusesResult: + def get_statuses(self, request: VacancyIdsRequest) -> VacancyStatusesResult: return request_public_model( self.transport, "POST", "/job/v2/vacancies/statuses", - context=RequestContext("jobs.vacancies.get_statuses_v2", allow_retry=True), + context=RequestContext("jobs.vacancies.get_statuses", allow_retry=True), mapper=map_vacancy_statuses, json_body=request.to_payload(), ) - def update_v2(self, *, vacancy_uuid: str, request: VacancyUpdateRequest) -> JobActionResult: + def update(self, *, vacancy_uuid: str, request: VacancyUpdateRequest) -> JobActionResult: return request_public_model( self.transport, "POST", f"/job/v2/vacancies/update/{vacancy_uuid}", - context=RequestContext("jobs.vacancies.update_v2", allow_retry=True), + context=RequestContext("jobs.vacancies.update", allow_retry=True), mapper=map_job_action, json_body=request.to_payload(), ) - def get_item_v2( + def get_item( self, *, vacancy_id: int | str, query: VacanciesQuery | None = None ) -> VacancyInfo: return request_public_model( self.transport, "GET", f"/job/v2/vacancies/{vacancy_id}", - context=RequestContext("jobs.vacancies.get_item_v2"), + context=RequestContext("jobs.vacancies.get_item"), mapper=map_vacancy_item, params=query.to_params() if query is not None else None, ) - def auto_renewal_v2( + def update_auto_renewal( self, *, vacancy_uuid: str, @@ -327,7 +327,7 @@ def auto_renewal_v2( self.transport, "PUT", f"/job/v2/vacancies/{vacancy_uuid}/auto_renewal", - context=RequestContext("jobs.vacancies.auto_renewal_v2", allow_retry=True), + context=RequestContext("jobs.vacancies.update_auto_renewal", allow_retry=True), mapper=map_job_action, json_body=request.to_payload(), ) diff --git a/avito/jobs/domain.py b/avito/jobs/domain.py index b17e3cc..95c9119 100644 --- a/avito/jobs/domain.py +++ b/avito/jobs/domain.py @@ -60,8 +60,8 @@ class Vacancy(DomainObject): def create(self, *, request: VacancyCreateRequest, version: int = 2) -> JobActionResult: client = VacanciesClient(self.transport) if version == 1: - return client.create_v1(request) - return client.create_v2(request) + return client.create_classic(request) + return client.create(request) def update( self, @@ -73,17 +73,17 @@ def update( ) -> JobActionResult: client = VacanciesClient(self.transport) if version == 1: - return client.update_v1( + return client.update_classic( vacancy_id=vacancy_id or self._require_resource_id(), request=request ) - return client.update_v2( + return client.update( vacancy_uuid=vacancy_uuid or self._require_resource_id(), request=request ) def delete( self, *, request: VacancyArchiveRequest, vacancy_id: int | str | None = None ) -> JobActionResult: - return VacanciesClient(self.transport).archive_v1( + return VacanciesClient(self.transport).archive( vacancy_id=vacancy_id or self._require_resource_id(), request=request, ) @@ -91,32 +91,32 @@ def delete( def prolongate( self, *, request: VacancyProlongateRequest, vacancy_id: int | str | None = None ) -> JobActionResult: - return VacanciesClient(self.transport).prolongate_v1( + return VacanciesClient(self.transport).prolongate( vacancy_id=vacancy_id or self._require_resource_id(), request=request, ) def list(self, *, query: VacanciesQuery | None = None) -> VacanciesResult: - return VacanciesClient(self.transport).list_v2(query=query) + return VacanciesClient(self.transport).list(query=query) def get( self, *, vacancy_id: int | str | None = None, query: VacanciesQuery | None = None ) -> VacancyInfo: - return VacanciesClient(self.transport).get_item_v2( + return VacanciesClient(self.transport).get_item( vacancy_id=vacancy_id or self._require_resource_id(), query=query, ) def get_by_ids(self, *, request: VacancyIdsRequest) -> VacanciesResult: - return VacanciesClient(self.transport).get_by_ids_v2(request) + return VacanciesClient(self.transport).get_by_ids(request) def get_statuses(self, *, request: VacancyIdsRequest) -> VacancyStatusesResult: - return VacanciesClient(self.transport).get_statuses_v2(request) + return VacanciesClient(self.transport).get_statuses(request) def update_auto_renewal( self, *, request: VacancyAutoRenewalRequest, vacancy_uuid: str | None = None ) -> JobActionResult: - return VacanciesClient(self.transport).auto_renewal_v2( + return VacanciesClient(self.transport).update_auto_renewal( vacancy_uuid=vacancy_uuid or self._require_resource_id(), request=request, ) diff --git a/avito/orders/client.py b/avito/orders/client.py index 71dc9cb..06d3301 100644 --- a/avito/orders/client.py +++ b/avito/orders/client.py @@ -341,74 +341,76 @@ def update_terms(self, *, tariff_id: str, request: UpdateTermsRequest) -> Delive request, ) - def add_tariff_v2(self, request: AddTariffV2Request) -> DeliveryEntityResult: - return self._post("/delivery-sandbox/tariffsV2", "orders.sandbox.add_tariff_v2", request) + def add_tariff(self, request: AddTariffV2Request) -> DeliveryEntityResult: + return self._post("/delivery-sandbox/tariffsV2", "orders.sandbox.add_tariff", request) - def v1_cancel_announcement( + def cancel_sandbox_announcement( self, request: SandboxCancelAnnouncementRequest ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/cancelAnnouncement", - "orders.sandbox.v1_cancel_announcement", + "orders.sandbox.cancel_sandbox_announcement", request, ) - def v1_cancel_parcel(self, request: CancelSandboxParcelRequest) -> DeliveryEntityResult: + def cancel_sandbox_parcel(self, request: CancelSandboxParcelRequest) -> DeliveryEntityResult: return self._post( - "/delivery-sandbox/v1/cancelParcel", "orders.sandbox.v1_cancel_parcel", request + "/delivery-sandbox/v1/cancelParcel", "orders.sandbox.cancel_sandbox_parcel", request ) - def v1_change_parcel(self, request: ChangeParcelRequest) -> DeliveryEntityResult: + def change_sandbox_parcel(self, request: ChangeParcelRequest) -> DeliveryEntityResult: return self._post( - "/delivery-sandbox/v1/changeParcel", "orders.sandbox.v1_change_parcel", request + "/delivery-sandbox/v1/changeParcel", "orders.sandbox.change_sandbox_parcel", request ) - def v1_create_announcement( + def create_sandbox_announcement( self, request: SandboxCreateAnnouncementRequest ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/createAnnouncement", - "orders.sandbox.v1_create_announcement", + "orders.sandbox.create_sandbox_announcement", request, ) - def v1_get_announcement_event( + def get_sandbox_announcement_event( self, request: SandboxGetAnnouncementEventRequest ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/getAnnouncementEvent", - "orders.sandbox.v1_get_announcement_event", + "orders.sandbox.get_sandbox_announcement_event", request, ) - def v1_get_change_parcel_info( + def get_sandbox_change_parcel_info( self, request: GetChangeParcelInfoRequest ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/getChangeParcelInfo", - "orders.sandbox.v1_get_change_parcel_info", + "orders.sandbox.get_sandbox_change_parcel_info", request, ) - def v1_get_parcel_info(self, request: GetSandboxParcelInfoRequest) -> DeliveryEntityResult: + def get_sandbox_parcel_info( + self, request: GetSandboxParcelInfoRequest + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/getParcelInfo", - "orders.sandbox.v1_get_parcel_info", + "orders.sandbox.get_sandbox_parcel_info", request, ) - def v1_get_registered_parcel_id( + def get_sandbox_registered_parcel_id( self, request: GetRegisteredParcelIdRequest ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/getRegisteredParcelID", - "orders.sandbox.v1_get_registered_parcel_id", + "orders.sandbox.get_sandbox_registered_parcel_id", request, ) - def create_parcel_v2(self, request: DeliveryParcelRequest) -> DeliveryEntityResult: + def create_parcel(self, request: DeliveryParcelRequest) -> DeliveryEntityResult: return self._post( - "/delivery-sandbox/v2/createParcel", "orders.sandbox.create_parcel_v2", request + "/delivery-sandbox/v2/createParcel", "orders.sandbox.create_parcel", request ) def _post( diff --git a/avito/orders/domain.py b/avito/orders/domain.py index 78506c0..c5fca5f 100644 --- a/avito/orders/domain.py +++ b/avito/orders/domain.py @@ -225,45 +225,47 @@ def update_terms(self, *, tariff_id: str, request: UpdateTermsRequest) -> Delive tariff_id=tariff_id, request=request ) - def add_tariff_v2(self, *, request: AddTariffV2Request) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).add_tariff_v2(request) + def add_tariff(self, *, request: AddTariffV2Request) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).add_tariff(request) def create_parcel(self, *, request: DeliveryParcelRequest) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).create_parcel_v2(request) + return SandboxDeliveryClient(self.transport).create_parcel(request) - def cancel_announcement_v1( + def cancel_sandbox_announcement( self, *, request: SandboxCancelAnnouncementRequest ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_cancel_announcement(request) + return SandboxDeliveryClient(self.transport).cancel_sandbox_announcement(request) - def cancel_parcel_v1(self, *, request: CancelSandboxParcelRequest) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_cancel_parcel(request) + def cancel_sandbox_parcel(self, *, request: CancelSandboxParcelRequest) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).cancel_sandbox_parcel(request) - def change_parcel_v1(self, *, request: ChangeParcelRequest) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_change_parcel(request) + def change_sandbox_parcel(self, *, request: ChangeParcelRequest) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).change_sandbox_parcel(request) - def create_announcement_v1( + def create_sandbox_announcement( self, *, request: SandboxCreateAnnouncementRequest ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_create_announcement(request) + return SandboxDeliveryClient(self.transport).create_sandbox_announcement(request) - def get_announcement_event_v1( + def get_sandbox_announcement_event( self, *, request: SandboxGetAnnouncementEventRequest ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_get_announcement_event(request) + return SandboxDeliveryClient(self.transport).get_sandbox_announcement_event(request) - def get_change_parcel_info_v1( + def get_sandbox_change_parcel_info( self, *, request: GetChangeParcelInfoRequest ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_get_change_parcel_info(request) + return SandboxDeliveryClient(self.transport).get_sandbox_change_parcel_info(request) - def get_parcel_info_v1(self, *, request: GetSandboxParcelInfoRequest) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_get_parcel_info(request) + def get_sandbox_parcel_info( + self, *, request: GetSandboxParcelInfoRequest + ) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).get_sandbox_parcel_info(request) - def get_registered_parcel_id_v1( + def get_sandbox_registered_parcel_id( self, *, request: GetRegisteredParcelIdRequest ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).v1_get_registered_parcel_id(request) + return SandboxDeliveryClient(self.transport).get_sandbox_registered_parcel_id(request) @dataclass(slots=True, frozen=True) diff --git a/avito/promotion/__init__.py b/avito/promotion/__init__.py index ae50f92..4b986f8 100644 --- a/avito/promotion/__init__.py +++ b/avito/promotion/__init__.py @@ -12,6 +12,8 @@ from avito.promotion.models import ( AutostrategyBudget, AutostrategyStat, + AutostrategyStatItem, + AutostrategyStatTotals, BbipBudgetOption, BbipDurationRange, BbipForecast, @@ -21,8 +23,15 @@ BbipSuggest, BbipSuggestsResult, CampaignActionResult, + CampaignDetailsResult, + CampaignForecast, + CampaignForecastRange, CampaignInfo, + CampaignItem, + CampaignListFilter, + CampaignOrderBy, CampaignsResult, + CampaignUpdateTimeFilter, CpaAuctionBidsResult, CreateItemBid, PromotionActionResult, @@ -55,6 +64,8 @@ "AutostrategyBudget", "AutostrategyCampaign", "AutostrategyStat", + "AutostrategyStatItem", + "AutostrategyStatTotals", "BbipBudgetOption", "BbipDurationRange", "BbipForecast", @@ -65,7 +76,14 @@ "BbipSuggest", "BbipSuggestsResult", "CampaignActionResult", + "CampaignDetailsResult", + "CampaignForecast", + "CampaignForecastRange", "CampaignInfo", + "CampaignItem", + "CampaignListFilter", + "CampaignOrderBy", + "CampaignUpdateTimeFilter", "CampaignsResult", "CpaAuction", "CpaAuctionBidsResult", diff --git a/avito/promotion/client.py b/avito/promotion/client.py index 8d0eb6f..0c5f944 100644 --- a/avito/promotion/client.py +++ b/avito/promotion/client.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass from avito.core import RequestContext, Transport @@ -31,7 +30,7 @@ BbipForecastsResult, BbipSuggestsResult, CampaignActionResult, - CampaignInfo, + CampaignDetailsResult, CampaignsResult, CancelTrxPromotionRequest, CpaAuctionBidsResult, @@ -142,16 +141,10 @@ def get_forecasts(self, request: CreateBbipForecastsRequest) -> BbipForecastsRes def create_order( self, request: CreateBbipOrderRequest, - *, - action: str = "create_order", - target: Mapping[str, object] | None = None, - request_payload: Mapping[str, object] | None = None, ) -> PromotionActionResult: """Подключает BBIP-услугу.""" - payload_to_send = ( - dict(request_payload) if request_payload is not None else request.to_payload() - ) + payload_to_send = request.to_payload() payload = self.transport.request_json( "PUT", "/promotion/v1/items/services/bbip/orders/create", @@ -160,8 +153,8 @@ def create_order( ) return map_promotion_action( payload, - action=action, - target=target or {"item_ids": [item.item_id for item in request.items]}, + action="create_order", + target={"item_ids": [item.item_id for item in request.items]}, request_payload=payload_to_send, ) @@ -187,16 +180,10 @@ class TrxPromoClient: def apply( self, request: CreateTrxPromotionApplyRequest, - *, - action: str = "apply", - target: Mapping[str, object] | None = None, - request_payload: Mapping[str, object] | None = None, ) -> PromotionActionResult: """Запускает TrxPromo.""" - payload_to_send = ( - dict(request_payload) if request_payload is not None else request.to_payload() - ) + payload_to_send = request.to_payload() payload = self.transport.request_json( "POST", "/trx-promo/1/apply", @@ -205,24 +192,18 @@ def apply( ) return map_promotion_action( payload, - action=action, - target=target or {"item_ids": [item.item_id for item in request.items]}, + action="apply", + target={"item_ids": [item.item_id for item in request.items]}, request_payload=payload_to_send, ) def cancel( self, request: CancelTrxPromotionRequest, - *, - action: str = "delete", - target: Mapping[str, object] | None = None, - request_payload: Mapping[str, object] | None = None, ) -> PromotionActionResult: """Останавливает TrxPromo.""" - payload_to_send = ( - dict(request_payload) if request_payload is not None else request.to_payload() - ) + payload_to_send = request.to_payload() payload = self.transport.request_json( "POST", "/trx-promo/1/cancel", @@ -231,8 +212,8 @@ def cancel( ) return map_promotion_action( payload, - action=action, - target=target or {"item_ids": list(request.item_ids)}, + action="delete", + target={"item_ids": list(request.item_ids)}, request_payload=payload_to_send, ) @@ -276,16 +257,10 @@ def get_user_bids( def create_item_bids( self, request: CreateItemBidsRequest, - *, - action: str = "create_item_bids", - target: Mapping[str, object] | None = None, - request_payload: Mapping[str, object] | None = None, ) -> PromotionActionResult: """Сохраняет новые ставки.""" - payload_to_send = ( - dict(request_payload) if request_payload is not None else request.to_payload() - ) + payload_to_send = request.to_payload() payload = self.transport.request_json( "POST", "/auction/1/bids", @@ -294,8 +269,8 @@ def create_item_bids( ) return map_promotion_action( payload, - action=action, - target=target or {"item_ids": [item.item_id for item in request.items]}, + action="create_item_bids", + target={"item_ids": [item.item_id for item in request.items]}, request_payload=payload_to_send, ) @@ -337,16 +312,10 @@ def get_promotions_by_item_ids( def delete_promotion( self, request: DeletePromotionRequest, - *, - action: str = "delete", - target: Mapping[str, object] | None = None, - request_payload: Mapping[str, object] | None = None, ) -> PromotionActionResult: """Останавливает продвижение с ценой целевого действия.""" - payload_to_send = ( - dict(request_payload) if request_payload is not None else request.to_payload() - ) + payload_to_send = request.to_payload() payload = self.transport.request_json( "POST", "/cpxpromo/1/remove", @@ -355,24 +324,18 @@ def delete_promotion( ) return map_promotion_action( payload, - action=action, - target=target or {"item_id": request.item_id}, + action="delete", + target={"item_id": request.item_id}, request_payload=payload_to_send, ) def update_auto_bid( self, request: UpdateAutoBidRequest, - *, - action: str = "update_auto", - target: Mapping[str, object] | None = None, - request_payload: Mapping[str, object] | None = None, ) -> PromotionActionResult: """Применяет автоматическую настройку.""" - payload_to_send = ( - dict(request_payload) if request_payload is not None else request.to_payload() - ) + payload_to_send = request.to_payload() payload = self.transport.request_json( "POST", "/cpxpromo/1/setAuto", @@ -381,24 +344,18 @@ def update_auto_bid( ) return map_promotion_action( payload, - action=action, - target=target or {"item_id": request.item_id}, + action="update_auto", + target={"item_id": request.item_id}, request_payload=payload_to_send, ) def update_manual_bid( self, request: UpdateManualBidRequest, - *, - action: str = "update_manual", - target: Mapping[str, object] | None = None, - request_payload: Mapping[str, object] | None = None, ) -> PromotionActionResult: """Применяет ручную настройку.""" - payload_to_send = ( - dict(request_payload) if request_payload is not None else request.to_payload() - ) + payload_to_send = request.to_payload() payload = self.transport.request_json( "POST", "/cpxpromo/1/setManual", @@ -407,8 +364,8 @@ def update_manual_bid( ) return map_promotion_action( payload, - action=action, - target=target or {"item_id": request.item_id}, + action="update_manual", + target={"item_id": request.item_id}, request_payload=payload_to_send, ) @@ -455,7 +412,7 @@ def edit_campaign(self, request: UpdateAutostrategyCampaignRequest) -> CampaignA json_body=request.to_payload(), ) - def get_campaign_info(self, request: GetAutostrategyCampaignInfoRequest) -> CampaignInfo: + def get_campaign_info(self, request: GetAutostrategyCampaignInfoRequest) -> CampaignDetailsResult: """Получает полную информацию о кампании.""" return request_public_model( diff --git a/avito/promotion/domain.py b/avito/promotion/domain.py index 1add877..83bef70 100644 --- a/avito/promotion/domain.py +++ b/avito/promotion/domain.py @@ -2,8 +2,9 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Sequence from dataclasses import dataclass +from typing import Any from avito.core import Transport, ValidationError from avito.promotion.client import ( @@ -22,7 +23,7 @@ BbipOrderItem, BbipSuggestsResult, CampaignActionResult, - CampaignInfo, + CampaignDetailsResult, CampaignsResult, CancelTrxPromotionRequest, CpaAuctionBidsResult, @@ -58,7 +59,7 @@ ) -def _validate_non_empty_items(name: str, items: Sequence[object]) -> None: +def _validate_non_empty_items(name: str, items: Sequence[Any]) -> None: if not items: raise ValidationError(f"`{name}` must contain at least one item.") @@ -82,8 +83,8 @@ def _validate_string_items(name: str, values: Sequence[str]) -> None: def _preview_result( *, action: str, - target: Mapping[str, object], - request_payload: Mapping[str, object], + target: dict[str, Any], + request_payload: dict[str, Any], ) -> PromotionActionResult: return PromotionActionResult( action=action, @@ -181,12 +182,7 @@ def create_order( target=target, request_payload=request_payload, ) - return BbipClient(self.transport).create_order( - request, - action="create_order", - target=target, - request_payload=request_payload, - ) + return BbipClient(self.transport).create_order(request) def get_suggests(self, *, item_ids: list[int] | None = None) -> BbipSuggestsResult: """Получает варианты бюджета BBIP.""" @@ -229,12 +225,7 @@ def apply( target = {"item_ids": [item.item_id for item in items]} if dry_run: return _preview_result(action="apply", target=target, request_payload=request_payload) - return TrxPromoClient(self.transport).apply( - request, - action="apply", - target=target, - request_payload=request_payload, - ) + return TrxPromoClient(self.transport).apply(request) def delete( self, @@ -251,12 +242,7 @@ def delete( target = {"item_ids": list(resolved_item_ids)} if dry_run: return _preview_result(action="delete", target=target, request_payload=request_payload) - return TrxPromoClient(self.transport).cancel( - request, - action="delete", - target=target, - request_payload=request_payload, - ) + return TrxPromoClient(self.transport).cancel(request) def get_commissions(self, *, item_ids: list[int] | None = None) -> TrxCommissionsResult: """Получает доступные комиссии TrxPromo.""" @@ -336,12 +322,7 @@ def delete( target = {"item_id": resolved_item_id} if dry_run: return _preview_result(action="delete", target=target, request_payload=request_payload) - return TargetActionPriceClient(self.transport).delete_promotion( - request, - action="delete", - target=target, - request_payload=request_payload, - ) + return TargetActionPriceClient(self.transport).delete_promotion(request) def update_auto( self, @@ -373,12 +354,7 @@ def update_auto( target=target, request_payload=request_payload, ) - return TargetActionPriceClient(self.transport).update_auto_bid( - request, - action="update_auto", - target=target, - request_payload=request_payload, - ) + return TargetActionPriceClient(self.transport).update_auto_bid(request) def update_manual( self, @@ -411,12 +387,7 @@ def update_manual( target=target, request_payload=request_payload, ) - return TargetActionPriceClient(self.transport).update_manual_bid( - request, - action="update_manual", - target=target, - request_payload=request_payload, - ) + return TargetActionPriceClient(self.transport).update_manual_bid(request) def _require_item_id(self) -> int: if self.resource_id is None: @@ -446,7 +417,7 @@ def update(self, *, request: UpdateAutostrategyCampaignRequest) -> CampaignActio return AutostrategyClient(self.transport).edit_campaign(request) - def get(self, *, campaign_id: int | None = None) -> CampaignInfo: + def get(self, *, campaign_id: int | None = None) -> CampaignDetailsResult: """Получает полную информацию о кампании.""" return AutostrategyClient(self.transport).get_campaign_info( @@ -455,18 +426,21 @@ def get(self, *, campaign_id: int | None = None) -> CampaignInfo: ) ) - def delete(self, *, campaign_id: int | None = None) -> CampaignActionResult: + def delete(self, *, version: int, campaign_id: int | None = None) -> CampaignActionResult: """Останавливает кампанию.""" return AutostrategyClient(self.transport).stop_campaign( - StopAutostrategyCampaignRequest(campaign_id=campaign_id or self._require_campaign_id()) + StopAutostrategyCampaignRequest( + campaign_id=campaign_id or self._require_campaign_id(), + version=version, + ) ) def list(self, *, request: ListAutostrategyCampaignsRequest | None = None) -> CampaignsResult: """Получает список кампаний.""" return AutostrategyClient(self.transport).list_campaigns( - request or ListAutostrategyCampaignsRequest(payload={}) + request or ListAutostrategyCampaignsRequest(limit=100) ) def get_stat(self, *, campaign_id: int | None = None) -> AutostrategyStat: diff --git a/avito/promotion/mappers.py b/avito/promotion/mappers.py index 5a50eea..68418a8 100644 --- a/avito/promotion/mappers.py +++ b/avito/promotion/mappers.py @@ -11,6 +11,8 @@ AutostrategyBudgetPoint, AutostrategyPriceRange, AutostrategyStat, + AutostrategyStatItem, + AutostrategyStatTotals, BbipBudgetOption, BbipDurationRange, BbipForecast, @@ -18,7 +20,11 @@ BbipSuggest, BbipSuggestsResult, CampaignActionResult, + CampaignDetailsResult, + CampaignForecast, + CampaignForecastRange, CampaignInfo, + CampaignItem, CampaignsResult, CpaAuctionBidOption, CpaAuctionBidsResult, @@ -518,10 +524,9 @@ def map_autostrategy_budget(payload: object) -> AutostrategyBudget: """Преобразует расчет бюджета автокампании.""" data = _expect_mapping(payload) - budget_payload = _mapping(data, "budget") - source = budget_payload or data + source = _mapping(data, "budget") return AutostrategyBudget( - budget_id=_str(data, "budgetId", "budgetID", "id"), + calc_id=_int(data, "calcId"), recommended=_map_budget_point(_mapping(source, "recommended")), minimal=_map_budget_point(_mapping(source, "minimal")), maximal=_map_budget_point(_mapping(source, "maximal")), @@ -559,33 +564,83 @@ def map_campaign_action(payload: object) -> CampaignActionResult: """Преобразует результат операции с автокампанией.""" data = _expect_mapping(payload) - return CampaignActionResult( - campaign_id=_int(data, "campaignId", "campaignID", "id"), - status=_str(data, "status"), + return CampaignActionResult(campaign=_map_campaign(_mapping(data, "campaign"))) + + +def _map_campaign(payload: Payload) -> CampaignInfo | None: + if not payload: + return None + return CampaignInfo( + campaign_id=_int(payload, "campaignId"), + campaign_type=_str(payload, "campaignType"), + budget=_int(payload, "budget"), + balance=_int(payload, "balance"), + create_time=_str(payload, "createTime"), + description=_str(payload, "description"), + finish_time=_str(payload, "finishTime"), + items_count=_int(payload, "itemsCount"), + start_time=_str(payload, "startTime"), + status_id=_int(payload, "statusId"), + title=_str(payload, "title"), + update_time=_str(payload, "updateTime"), + user_id=_int(payload, "userId"), + version=_int(payload, "version"), ) -def map_campaign_info(payload: object) -> CampaignInfo: - """Преобразует информацию об автокампании.""" +def map_campaign_info(payload: object) -> CampaignDetailsResult: + """Преобразует полную информацию об автокампании.""" data = _expect_mapping(payload) - source = _mapping(data, "campaign") or data - return CampaignInfo( - campaign_id=_int(source, "campaignId", "campaignID", "id"), - campaign_type=_str(source, "campaignType"), - status=_str(source, "status"), - budget=_int(source, "budget"), - balance=_int(source, "balance"), - title=_str(source, "title", "name"), + return CampaignDetailsResult( + campaign=_map_campaign(_mapping(data, "campaign")), + forecast=_map_campaign_forecast(_mapping(data, "forecast")), + items=[_map_campaign_item(item) for item in _list(data, "items")], + ) + + +def _map_campaign_forecast(payload: Payload) -> CampaignForecast | None: + if not payload: + return None + return CampaignForecast( + calls=_map_campaign_forecast_range(_mapping(payload, "calls")), + views=_map_campaign_forecast_range(_mapping(payload, "views")), + ) + + +def _map_campaign_forecast_range(payload: Payload) -> CampaignForecastRange | None: + if not payload: + return None + return CampaignForecastRange( + from_value=_int(payload, "from"), + to_value=_int(payload, "to"), ) +def _map_campaign_item(payload: Payload) -> CampaignItem: + return CampaignItem( + item_id=_int(payload, "itemId"), + is_active=_bool(payload, "isActive"), + ) + + +def map_campaign_list_item(payload: object) -> CampaignInfo: + """Преобразует элемент списка автокампаний.""" + + data = _expect_mapping(payload) + campaign = _map_campaign(data) + if campaign is None: + raise ResponseMappingError("Не удалось смэппить кампанию.", payload=payload) + return campaign + + def map_campaigns(payload: object) -> CampaignsResult: """Преобразует список автокампаний.""" data = _expect_mapping(payload) return CampaignsResult( - items=[map_campaign_info(item) for item in _items_payload(data)], + items=[map_campaign_list_item(item) for item in _list(data, "campaigns")], + total_count=_int(data, "totalCount"), ) @@ -593,10 +648,26 @@ def map_autostrategy_stat(payload: object) -> AutostrategyStat: """Преобразует статистику автокампании.""" data = _expect_mapping(payload) - source = _mapping(data, "stat") or data return AutostrategyStat( - campaign_id=_int(source, "campaignId", "campaignID", "id"), - views=_int(source, "views"), - contacts=_int(source, "contacts", "leads"), - spend=_int(source, "spend", "spendTotal"), + items=[_map_autostrategy_stat_item(item) for item in _list(data, "stat")], + totals=_map_autostrategy_stat_totals(_mapping(data, "totals")), + ) + + +def _map_autostrategy_stat_item(payload: Payload) -> AutostrategyStatItem: + return AutostrategyStatItem( + date=_str(payload, "date"), + calls=_int(payload, "calls"), + views=_int(payload, "views"), + calls_forecast=_map_campaign_forecast_range(_mapping(payload, "callsForecast")), + views_forecast=_map_campaign_forecast_range(_mapping(payload, "viewsForecast")), + ) + + +def _map_autostrategy_stat_totals(payload: Payload) -> AutostrategyStatTotals | None: + if not payload: + return None + return AutostrategyStatTotals( + calls=_int(payload, "calls"), + views=_int(payload, "views"), ) diff --git a/avito/promotion/models.py b/avito/promotion/models.py index a618ac0..ac1011e 100644 --- a/avito/promotion/models.py +++ b/avito/promotion/models.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass, field from avito.core.serialization import SerializableModel, enable_module_serialization @@ -230,13 +229,13 @@ class PromotionActionResult(SerializableModel): """Стабильный результат write-операции продвижения.""" action: str - target: Mapping[str, object] | None + target: dict[str, object] | None status: str applied: bool - request_payload: Mapping[str, object] | None = None + request_payload: dict[str, object] | None = None warnings: list[str] = field(default_factory=list) upstream_reference: str | None = None - details: Mapping[str, object] = field(default_factory=dict) + details: dict[str, object] = field(default_factory=dict) @dataclass(slots=True, frozen=True) @@ -593,7 +592,7 @@ class AutostrategyPriceRange: class AutostrategyBudget: """Расчет бюджета автокампании.""" - budget_id: str | None + calc_id: int | None recommended: AutostrategyBudgetPoint | None minimal: AutostrategyBudgetPoint | None maximal: AutostrategyBudgetPoint | None @@ -604,20 +603,29 @@ class AutostrategyBudget: class CreateAutostrategyBudgetRequest: """Запрос расчета бюджета кампании.""" - payload: Mapping[str, object] + campaign_type: str + start_time: str | None = None + finish_time: str | None = None + items: list[int] | None = None def to_payload(self) -> dict[str, object]: """Сериализует запрос расчета бюджета.""" - return dict(self.payload) + payload: dict[str, object] = {"campaignType": self.campaign_type} + if self.start_time is not None: + payload["startTime"] = self.start_time + if self.finish_time is not None: + payload["finishTime"] = self.finish_time + if self.items is not None: + payload["items"] = list(self.items) + return payload @dataclass(slots=True, frozen=True) class CampaignActionResult: """Результат операции с автокампанией.""" - campaign_id: int | None - status: str | None + campaign: CampaignInfo | None @dataclass(slots=True, frozen=True) @@ -626,10 +634,51 @@ class CampaignInfo: campaign_id: int | None campaign_type: str | None - status: str | None budget: int | None balance: int | None + create_time: str | None + description: str | None + finish_time: str | None + items_count: int | None + start_time: str | None + status_id: int | None title: str | None + update_time: str | None + user_id: int | None + version: int | None + + +@dataclass(slots=True, frozen=True) +class CampaignForecastRange(SerializableModel): + """Диапазон прогноза кампании.""" + + from_value: int | None + to_value: int | None + + +@dataclass(slots=True, frozen=True) +class CampaignForecast(SerializableModel): + """Прогноз кампании автостратегии.""" + + calls: CampaignForecastRange | None + views: CampaignForecastRange | None + + +@dataclass(slots=True, frozen=True) +class CampaignItem(SerializableModel): + """Объявление внутри автокампании.""" + + item_id: int | None + is_active: bool | None + + +@dataclass(slots=True, frozen=True) +class CampaignDetailsResult(SerializableModel): + """Полный ответ ручки информации о кампании.""" + + campaign: CampaignInfo | None + forecast: CampaignForecast | None + items: list[CampaignItem] @dataclass(slots=True, frozen=True) @@ -637,40 +686,94 @@ class CampaignsResult: """Список автокампаний.""" items: list[CampaignInfo] + total_count: int | None = None @dataclass(slots=True, frozen=True) class AutostrategyStat: """Статистика автокампании.""" - campaign_id: int | None - views: int | None - contacts: int | None - spend: int | None + items: list[AutostrategyStatItem] + totals: AutostrategyStatTotals | None @dataclass(slots=True, frozen=True) class CreateAutostrategyCampaignRequest: """Запрос создания автокампании.""" - payload: Mapping[str, object] + campaign_type: 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: str | None = None + items: list[int] | None = None + start_time: str | None = None def to_payload(self) -> dict[str, object]: """Сериализует запрос создания кампании.""" - return dict(self.payload) + payload: dict[str, object] = { + "campaignType": self.campaign_type, + "title": self.title, + } + if self.budget is not None: + payload["budget"] = self.budget + if self.budget_bonus is not None: + payload["budgetBonus"] = self.budget_bonus + if self.budget_real is not None: + payload["budgetReal"] = self.budget_real + if self.calc_id is not None: + payload["calcId"] = self.calc_id + if self.description is not None: + payload["description"] = self.description + if self.finish_time is not None: + payload["finishTime"] = self.finish_time + if self.items is not None: + payload["items"] = list(self.items) + if self.start_time is not None: + payload["startTime"] = self.start_time + return payload @dataclass(slots=True, frozen=True) class UpdateAutostrategyCampaignRequest: """Запрос редактирования автокампании.""" - payload: Mapping[str, object] + campaign_id: int + version: int + budget: int | None = None + calc_id: int | None = None + description: str | None = None + finish_time: str | None = None + items: list[int] | None = None + start_time: str | None = None + title: str | None = None def to_payload(self) -> dict[str, object]: """Сериализует запрос редактирования кампании.""" - return dict(self.payload) + payload: dict[str, object] = { + "campaignId": self.campaign_id, + "version": self.version, + } + if self.budget is not None: + payload["budget"] = self.budget + if self.calc_id is not None: + payload["calcId"] = self.calc_id + if self.description is not None: + payload["description"] = self.description + if self.finish_time is not None: + payload["finishTime"] = self.finish_time + if self.items is not None: + payload["items"] = list(self.items) + if self.start_time is not None: + payload["startTime"] = self.start_time + if self.title is not None: + payload["title"] = self.title + return payload @dataclass(slots=True, frozen=True) @@ -690,23 +793,77 @@ class StopAutostrategyCampaignRequest: """Запрос остановки автокампании.""" campaign_id: int + version: int def to_payload(self) -> dict[str, object]: """Сериализует запрос остановки кампании.""" - return {"campaignId": self.campaign_id} + return {"campaignId": self.campaign_id, "version": self.version} + + +@dataclass(slots=True, frozen=True) +class CampaignUpdateTimeFilter: + """Фильтр кампаний по времени обновления.""" + + from_time: str | None = None + to_time: str | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {} + if self.from_time is not None: + payload["from"] = self.from_time + if self.to_time is not None: + payload["to"] = self.to_time + return payload + + +@dataclass(slots=True, frozen=True) +class CampaignListFilter: + """Фильтр списка кампаний.""" + + by_update_time: CampaignUpdateTimeFilter | None = None + + def to_payload(self) -> dict[str, object]: + payload: dict[str, object] = {} + if self.by_update_time is not None: + payload["byUpdateTime"] = self.by_update_time.to_payload() + return payload + + +@dataclass(slots=True, frozen=True) +class CampaignOrderBy: + """Параметры сортировки списка кампаний.""" + + column: str + direction: str + + def to_payload(self) -> dict[str, object]: + return {"column": self.column, "direction": self.direction} @dataclass(slots=True, frozen=True) class ListAutostrategyCampaignsRequest: """Запрос списка автокампаний.""" - payload: Mapping[str, object] = field(default_factory=dict) + limit: int + offset: int | None = None + status_id: list[int] | None = None + order_by: list[CampaignOrderBy] | None = None + filter: CampaignListFilter | None = None def to_payload(self) -> dict[str, object]: """Сериализует запрос списка кампаний.""" - return dict(self.payload) + payload: dict[str, object] = {"limit": self.limit} + if self.offset is not None: + payload["offset"] = self.offset + if self.status_id is not None: + payload["statusId"] = list(self.status_id) + if self.order_by is not None: + payload["orderBy"] = [item.to_payload() for item in self.order_by] + if self.filter is not None: + payload["filter"] = self.filter.to_payload() + return payload @dataclass(slots=True, frozen=True) @@ -721,6 +878,25 @@ def to_payload(self) -> dict[str, object]: return {"campaignId": self.campaign_id} +@dataclass(slots=True, frozen=True) +class AutostrategyStatItem(SerializableModel): + """Статистика кампании за день.""" + + date: str | None + calls: int | None + views: int | None + calls_forecast: CampaignForecastRange | None = None + views_forecast: CampaignForecastRange | None = None + + +@dataclass(slots=True, frozen=True) +class AutostrategyStatTotals(SerializableModel): + """Суммарная статистика кампании.""" + + calls: int | None + views: int | None + + PromotionOrder = PromotionOrderInfo PromotionForecast = BbipForecast diff --git a/avito/ratings/client.py b/avito/ratings/client.py index d1845f5..e37601f 100644 --- a/avito/ratings/client.py +++ b/avito/ratings/client.py @@ -21,7 +21,7 @@ class RatingsClient: transport: Transport - def create_review_answer_v1(self, request: CreateReviewAnswerRequest) -> ReviewAnswerInfo: + def create_review_answer(self, request: CreateReviewAnswerRequest) -> ReviewAnswerInfo: payload = self.transport.request_json( "POST", "/ratings/v1/answers", @@ -30,7 +30,7 @@ def create_review_answer_v1(self, request: CreateReviewAnswerRequest) -> ReviewA ) return map_review_answer(payload) - def delete_review_answer_v1(self, *, answer_id: int | str) -> ReviewAnswerInfo: + def delete_review_answer(self, *, answer_id: int | str) -> ReviewAnswerInfo: payload = self.transport.request_json( "DELETE", f"/ratings/v1/answers/{answer_id}", @@ -38,7 +38,7 @@ def delete_review_answer_v1(self, *, answer_id: int | str) -> ReviewAnswerInfo: ) return map_review_answer(payload) - def get_ratings_info_v1(self) -> RatingProfileInfo: + def get_ratings_info(self) -> RatingProfileInfo: payload = self.transport.request_json( "GET", "/ratings/v1/info", @@ -46,7 +46,7 @@ def get_ratings_info_v1(self) -> RatingProfileInfo: ) return map_rating_profile(payload) - def list_reviews_v1(self, *, query: ReviewsQuery | None = None) -> ReviewsResult: + def list_reviews(self, *, query: ReviewsQuery | None = None) -> ReviewsResult: payload = self.transport.request_json( "GET", "/ratings/v1/reviews", diff --git a/avito/ratings/domain.py b/avito/ratings/domain.py index ca999c1..d06139e 100644 --- a/avito/ratings/domain.py +++ b/avito/ratings/domain.py @@ -29,8 +29,8 @@ class Review(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def list_reviews_v1(self, *, query: ReviewsQuery | None = None) -> ReviewsResult: - return RatingsClient(self.transport).list_reviews_v1(query=query) + def list(self, *, query: ReviewsQuery | None = None) -> ReviewsResult: + return RatingsClient(self.transport).list_reviews(query=query) @dataclass(slots=True, frozen=True) @@ -40,13 +40,13 @@ class ReviewAnswer(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def create_review_answer_v1(self, *, review_id: int, text: str) -> ReviewAnswerInfo: - return RatingsClient(self.transport).create_review_answer_v1( + def create(self, *, review_id: int, text: str) -> ReviewAnswerInfo: + return RatingsClient(self.transport).create_review_answer( CreateReviewAnswerRequest(review_id=review_id, text=text) ) - def delete_review_answer_v1(self, *, answer_id: int | str | None = None) -> ReviewAnswerInfo: - return RatingsClient(self.transport).delete_review_answer_v1( + def delete(self, *, answer_id: int | str | None = None) -> ReviewAnswerInfo: + return RatingsClient(self.transport).delete_review_answer( answer_id=answer_id or self._require_answer_id() ) @@ -63,8 +63,8 @@ class RatingProfile(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def get_ratings_info_v1(self) -> RatingProfileInfo: - return RatingsClient(self.transport).get_ratings_info_v1() + def get(self) -> RatingProfileInfo: + return RatingsClient(self.transport).get_ratings_info() __all__ = ("DomainObject", "RatingProfile", "Review", "ReviewAnswer") diff --git a/avito/realty/client.py b/avito/realty/client.py index 394b5a0..5cfb2d1 100644 --- a/avito/realty/client.py +++ b/avito/realty/client.py @@ -85,7 +85,7 @@ class RealtyAnalyticsClient: transport: Transport - def get_market_price_correspondence_v1( + def get_market_price_correspondence( self, *, item_id: int | str, price: int | str ) -> RealtyMarketPriceInfo: payload = self.transport.request_json( diff --git a/avito/realty/domain.py b/avito/realty/domain.py index 303cacc..3e907dd 100644 --- a/avito/realty/domain.py +++ b/avito/realty/domain.py @@ -138,13 +138,13 @@ class RealtyAnalyticsReport(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def get_market_price_correspondence_v1( + def get_market_price_correspondence( self, *, item_id: int | str | None = None, price: int | str, ) -> RealtyMarketPriceInfo: - return RealtyAnalyticsClient(self.transport).get_market_price_correspondence_v1( + return RealtyAnalyticsClient(self.transport).get_market_price_correspondence( item_id=item_id or self._require_item_id(), price=price, ) diff --git a/tests/test_auth.py b/tests/test_auth.py index 4e107e1..041f193 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -9,10 +9,10 @@ from avito import AvitoClient from avito.auth import ( + AlternateTokenClient, AuthProvider, AuthSettings, ClientCredentialsRequest, - LegacyTokenClient, RefreshTokenRequest, TokenClient, ) @@ -122,7 +122,7 @@ def test_token_client_maps_authentication_error() -> None: assert error.value.error_code == "invalid_client" -def test_legacy_token_alias_does_not_create_duplicate_public_client_api() -> None: +def test_alternate_token_flow_does_not_create_duplicate_public_client_api() -> None: settings = AvitoSettings( base_url="https://api.avito.ru", auth=AuthSettings(client_id="client-id", client_secret="client-secret"), @@ -133,27 +133,27 @@ def test_legacy_token_alias_does_not_create_duplicate_public_client_api() -> Non auth_provider = client.auth() assert isinstance(auth_provider.token_flow(), TokenClient) - assert isinstance(auth_provider.legacy_token_flow(), LegacyTokenClient) + assert isinstance(auth_provider.alternate_token_flow(), AlternateTokenClient) with pytest.raises(AttributeError): _ = client.legacy_auth # type: ignore[attr-defined] -def test_legacy_token_client_uses_same_public_contract_for_duplicate_token_path() -> None: +def test_alternate_token_client_uses_same_public_contract_for_duplicate_token_path() -> None: captured_paths: list[str] = [] def handler(request: httpx.Request) -> httpx.Response: captured_paths.append(request.url.path) return httpx.Response(200, json={"access_token": "legacy-access", "expires_in": 3600}) - legacy_token_client = LegacyTokenClient( + alternate_token_client = AlternateTokenClient( AuthSettings( - client_id="client-id", client_secret="client-secret", legacy_token_url="/token" + client_id="client-id", client_secret="client-secret", alternate_token_url="/token" ), client=make_token_http_client(httpx.MockTransport(handler)), ) - token_response = legacy_token_client.request_refresh_token( + token_response = alternate_token_client.request_refresh_token( RefreshTokenRequest( client_id="client-id", client_secret="client-secret", diff --git a/tests/test_facade.py b/tests/test_facade.py index 388d8d8..12589e0 100644 --- a/tests/test_facade.py +++ b/tests/test_facade.py @@ -1,6 +1,8 @@ +import pytest + from avito import AuthSettings, AvitoClient, AvitoSettings from avito.accounts import Account, AccountHierarchy -from avito.ads import Ad, AdPromotion, AdStats, AutoloadLegacy, AutoloadProfile, AutoloadReport +from avito.ads import Ad, AdPromotion, AdStats, AutoloadArchive, AutoloadProfile, AutoloadReport from avito.auth import AuthProvider from avito.autoteka import ( AutotekaMonitoring, @@ -9,7 +11,7 @@ AutotekaValuation, AutotekaVehicle, ) -from avito.cpa import CallTrackingCall, CpaCall, CpaChat, CpaLead, CpaLegacy +from avito.cpa import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead from avito.jobs import Application, JobDictionary, JobWebhook, Resume, Vacancy from avito.messenger import Chat, ChatMedia, ChatMessage, ChatWebhook, SpecialOfferCampaign from avito.orders import DeliveryOrder, DeliveryTask, Order, OrderLabel, SandboxDelivery, Stock @@ -39,7 +41,7 @@ def test_single_client_exposes_domain_factories() -> None: assert isinstance(client.ad_promotion(1), AdPromotion) assert isinstance(client.autoload_profile(1), AutoloadProfile) assert isinstance(client.autoload_report(1), AutoloadReport) - assert isinstance(client.autoload_legacy(1), AutoloadLegacy) + assert isinstance(client.autoload_archive(1), AutoloadArchive) assert isinstance(client.chat("chat-1", user_id=1), Chat) assert isinstance(client.chat_message("msg-1", chat_id="chat-1", user_id=1), ChatMessage) assert isinstance(client.chat_webhook(), ChatWebhook) @@ -65,7 +67,7 @@ def test_single_client_exposes_domain_factories() -> None: assert isinstance(client.cpa_lead(1), CpaLead) assert isinstance(client.cpa_chat(1), CpaChat) assert isinstance(client.cpa_call(1), CpaCall) - assert isinstance(client.cpa_legacy(1), CpaLegacy) + assert isinstance(client.cpa_archive(1), CpaArchive) assert isinstance(client.call_tracking_call(1), CallTrackingCall) assert isinstance(client.autoteka_vehicle(1), AutotekaVehicle) assert isinstance(client.autoteka_report(1), AutotekaReport) @@ -84,3 +86,15 @@ def test_single_client_exposes_domain_factories() -> None: def test_package_exports_auth_settings_as_public_config_contract() -> None: assert AuthSettings.__name__ == "AuthSettings" + + +def test_removed_legacy_factory_names_are_absent() -> None: + client = AvitoClient( + AvitoSettings(auth=AuthSettings(client_id="client-id", client_secret="client-secret")) + ) + + with pytest.raises(AttributeError): + _ = client.autoload_legacy # type: ignore[attr-defined] + + with pytest.raises(AttributeError): + _ = client.cpa_legacy # type: ignore[attr-defined] diff --git a/tests/test_public_api_shape.py b/tests/test_public_api_shape.py index 5f612c6..ac67011 100644 --- a/tests/test_public_api_shape.py +++ b/tests/test_public_api_shape.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib import inspect import avito.autoteka as autoteka @@ -71,3 +72,80 @@ def test_chat_media_upload_images_no_longer_accepts_raw_dict() -> None: signature_text = str(inspect.signature(ChatMedia.upload_images)) assert "dict[str, object]" not in signature_text assert "UploadImageFile" in signature_text + + +def test_public_domain_and_client_methods_avoid_raw_dict_mapping_object_signatures() -> None: + module_names = ( + "avito.accounts.domain", + "avito.accounts.client", + "avito.ads.domain", + "avito.ads.client", + "avito.autoteka.domain", + "avito.autoteka.client", + "avito.cpa.domain", + "avito.cpa.client", + "avito.jobs.domain", + "avito.jobs.client", + "avito.messenger.domain", + "avito.messenger.client", + "avito.orders.domain", + "avito.orders.client", + "avito.promotion.domain", + "avito.promotion.client", + "avito.ratings.domain", + "avito.ratings.client", + "avito.realty.domain", + "avito.realty.client", + "avito.tariffs.domain", + "avito.tariffs.client", + ) + banned_tokens = ("Mapping[str, object]", "dict[str, object]", "object]") + offenders: list[str] = [] + + for module_name in module_names: + module = importlib.import_module(module_name) + for _, cls in inspect.getmembers(module, inspect.isclass): + if cls.__module__ != module_name or cls.__name__.startswith("_"): + continue + for method_name, method in inspect.getmembers(cls, inspect.isfunction): + if method_name.startswith("_"): + continue + signature_text = str(inspect.signature(method)) + if any(token in signature_text for token in banned_tokens): + offenders.append(f"{module_name}.{cls.__name__}.{method_name}{signature_text}") + + assert offenders == [] + + +def test_public_surface_does_not_expose_legacy_or_version_suffixed_method_names() -> None: + module_names = ( + "avito.client.client", + "avito.auth.provider", + "avito.ads.domain", + "avito.cpa.domain", + "avito.cpa.client", + "avito.jobs.domain", + "avito.jobs.client", + "avito.orders.domain", + "avito.orders.client", + "avito.ratings.domain", + "avito.realty.domain", + ) + banned_fragments = ("legacy_",) + banned_suffixes = ("_v1", "_v2") + offenders: list[str] = [] + + for module_name in module_names: + module = importlib.import_module(module_name) + for _, cls in inspect.getmembers(module, inspect.isclass): + if cls.__module__ != module_name or cls.__name__.startswith("_"): + continue + for method_name, method in inspect.getmembers(cls, inspect.isfunction): + if method_name.startswith("_"): + continue + if any(fragment in method_name for fragment in banned_fragments) or method_name.endswith( + banned_suffixes + ): + offenders.append(f"{module_name}.{cls.__name__}.{method_name}{inspect.signature(method)}") + + assert offenders == [] diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py new file mode 100644 index 0000000..bc26829 --- /dev/null +++ b/tests/test_readme_examples.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import inspect + +from avito import AvitoClient +from avito.promotion.models import ( + CampaignListFilter, + CampaignOrderBy, + CampaignUpdateTimeFilter, + CreateAutostrategyBudgetRequest, + ListAutostrategyCampaignsRequest, +) +from avito.ratings import Review + + +def test_readme_uses_current_autostrategy_request_models() -> None: + budget_request = CreateAutostrategyBudgetRequest( + campaign_type="AS", + start_time="2026-04-20T00:00:00Z", + finish_time="2026-04-27T00:00:00Z", + items=[42, 43], + ) + campaigns_request = ListAutostrategyCampaignsRequest( + limit=50, + status_id=[1, 2], + order_by=[CampaignOrderBy(column="startTime", direction="asc")], + filter=CampaignListFilter( + by_update_time=CampaignUpdateTimeFilter( + from_time="2026-04-01T00:00:00Z", + to_time="2026-04-30T00:00:00Z", + ) + ), + ) + + assert budget_request.to_payload() == { + "campaignType": "AS", + "startTime": "2026-04-20T00:00:00Z", + "finishTime": "2026-04-27T00:00:00Z", + "items": [42, 43], + } + assert campaigns_request.to_payload() == { + "limit": 50, + "statusId": [1, 2], + "orderBy": [{"column": "startTime", "direction": "asc"}], + "filter": { + "byUpdateTime": { + "from": "2026-04-01T00:00:00Z", + "to": "2026-04-30T00:00:00Z", + } + }, + } + + +def test_readme_references_current_public_method_names() -> None: + assert hasattr(AvitoClient, "autoload_archive") + assert hasattr(AvitoClient, "cpa_archive") + assert hasattr(Review, "list") + assert not hasattr(AvitoClient, "autoload_legacy") + assert not hasattr(AvitoClient, "cpa_legacy") + assert not hasattr(Review, "list_reviews_v1") + + review_signature = str(inspect.signature(Review.list)) + assert "query" in review_signature diff --git a/tests/test_stage11_mock_transport_suite.py b/tests/test_stage11_mock_transport_suite.py index b7ceff0..3a7fef6 100644 --- a/tests/test_stage11_mock_transport_suite.py +++ b/tests/test_stage11_mock_transport_suite.py @@ -363,7 +363,7 @@ def test_mock_transport_happy_path_write_methods_and_dry_run( previews = [ ad_promotion.apply_vas(codes=["xl"], dry_run=True), ad_promotion.apply_vas_package(package_code="turbo", dry_run=True), - ad_promotion.apply_vas_v2(codes=["highlight"], dry_run=True), + ad_promotion.apply_vas_direct(codes=["highlight"], dry_run=True), bbip.create_order( items=[BbipOrderItem(item_id=101, duration=7, price=1000, old_price=1200)], dry_run=True, @@ -389,7 +389,7 @@ def test_mock_transport_happy_path_write_methods_and_dry_run( applied = [ ad_promotion.apply_vas(codes=["xl"]), ad_promotion.apply_vas_package(package_code="turbo"), - ad_promotion.apply_vas_v2(codes=["highlight"]), + ad_promotion.apply_vas_direct(codes=["highlight"]), bbip.create_order( items=[BbipOrderItem(item_id=101, duration=7, price=1000, old_price=1200)] ), diff --git a/tests/test_stage11_realty_ratings_tariffs.py b/tests/test_stage11_realty_ratings_tariffs.py index 3a1b994..53ac4d6 100644 --- a/tests/test_stage11_realty_ratings_tariffs.py +++ b/tests/test_stage11_realty_ratings_tariffs.py @@ -121,7 +121,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) ) base = listing.update_base_params(request=RealtyBaseParamsUpdateRequest(min_stay_days=2)) - market = analytics.get_market_price_correspondence_v1(price=5000000) + market = analytics.get_market_price_correspondence(price=5000000) report = analytics.get_report_for_classified() assert updated_bookings.success is True @@ -180,10 +180,10 @@ def handler(request: httpx.Request) -> httpx.Response: profile = RatingProfile(transport) review = Review(transport) - created = answer.create_review_answer_v1(review_id=123, text="Спасибо за отзыв") - deleted = answer.delete_review_answer_v1() - info = profile.get_ratings_info_v1() - reviews = review.list_reviews_v1(query=ReviewsQuery(page=2)) + created = answer.create(review_id=123, text="Спасибо за отзыв") + deleted = answer.delete() + info = profile.get() + reviews = review.list(query=ReviewsQuery(page=2)) assert created.answer_id == "456" assert deleted.success is True diff --git a/tests/test_stage12_release_gate.py b/tests/test_stage12_release_gate.py index 05cd81f..53bca49 100644 --- a/tests/test_stage12_release_gate.py +++ b/tests/test_stage12_release_gate.py @@ -3,7 +3,7 @@ import httpx from avito import AvitoClient -from avito.auth import AuthProvider, LegacyTokenClient, TokenClient +from avito.auth import AlternateTokenClient, AuthProvider, TokenClient from avito.auth.settings import AuthSettings from avito.config import AvitoSettings from avito.core import Transport @@ -28,7 +28,7 @@ def test_debug_info_does_not_expose_secrets() -> None: def test_client_context_manager_closes_transport_and_auth_clients() -> None: transport_http_client = httpx.Client() token_http_client = httpx.Client() - legacy_http_client = httpx.Client() + alternate_http_client = httpx.Client() autoteka_http_client = httpx.Client() settings = AvitoSettings( @@ -37,7 +37,9 @@ def test_client_context_manager_closes_transport_and_auth_clients() -> None: auth_provider = AuthProvider( settings.auth, token_client=TokenClient(settings.auth, client=token_http_client), - legacy_token_client=LegacyTokenClient(settings.auth, client=legacy_http_client), + alternate_token_client=AlternateTokenClient( + settings.auth, client=alternate_http_client + ), autoteka_token_client=TokenClient(settings.auth, client=autoteka_http_client), ) client = AvitoClient(settings) @@ -51,5 +53,5 @@ def test_client_context_manager_closes_transport_and_auth_clients() -> None: assert transport_http_client.is_closed is True assert token_http_client.is_closed is True - assert legacy_http_client.is_closed is True + assert alternate_http_client.is_closed is True assert autoteka_http_client.is_closed is True diff --git a/tests/test_stage4_accounts_ads.py b/tests/test_stage4_accounts_ads.py index 6ab8491..d2338b1 100644 --- a/tests/test_stage4_accounts_ads.py +++ b/tests/test_stage4_accounts_ads.py @@ -5,7 +5,7 @@ import httpx from avito.accounts import Account, AccountHierarchy -from avito.ads import Ad, AdPromotion, AdStats, AutoloadLegacy, AutoloadProfile, AutoloadReport +from avito.ads import Ad, AdPromotion, AdStats, AutoloadArchive, AutoloadProfile, AutoloadReport from avito.auth import AuthSettings from avito.config import AvitoSettings from avito.core import Transport @@ -242,7 +242,7 @@ def handler(request: httpx.Request) -> httpx.Response: vas_prices = promotion.get_vas_prices(item_ids=[101], location_id=213) vas_apply = promotion.apply_vas(codes=["xl"]) package_apply = promotion.apply_vas_package(package_code="turbo") - vas_v2_apply = promotion.apply_vas_v2(codes=["highlight"]) + vas_v2_apply = promotion.apply_vas_direct(codes=["highlight"]) assert item.title == "Смартфон" assert items.total == 1 @@ -257,7 +257,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert vas_v2_apply.status == "v2_applied" -def test_autoload_domains_cover_profile_report_and_legacy_flows() -> None: +def test_autoload_domains_cover_profile_report_and_archive_flows() -> None: def handler(request: httpx.Request) -> httpx.Response: path = request.url.path if path == "/autoload/v2/profile" and request.method == "GET": @@ -334,7 +334,7 @@ def handler(request: httpx.Request) -> httpx.Response: transport = make_transport(httpx.MockTransport(handler)) profile = AutoloadProfile(transport) report = AutoloadReport(transport, resource_id=501) - legacy = AutoloadLegacy(transport, resource_id=401) + archive = AutoloadArchive(transport, resource_id=401) current_profile = profile.get() saved_profile = profile.save(is_enabled=True, email="feed@example.com") @@ -349,10 +349,10 @@ def handler(request: httpx.Request) -> httpx.Response: ad_ids = report.get_ad_ids_by_avito_ids(avito_ids=[9001, 9002]) avito_ids = report.get_avito_ids_by_ad_ids(ad_ids=[1, 2]) items_info = report.get_items_info(item_ids=[101]) - legacy_profile = legacy.get_profile() - legacy_saved = legacy.save_profile(email="legacy@example.com") - legacy_last = legacy.get_last_completed_report() - legacy_report = legacy.get_report() + legacy_profile = archive.get_profile() + legacy_saved = archive.save_profile(email="legacy@example.com") + legacy_last = archive.get_last_completed_report() + legacy_report = archive.get_report() assert current_profile.is_enabled is True assert saved_profile.success is True diff --git a/tests/test_stage4_promotion_write_contract.py b/tests/test_stage4_promotion_write_contract.py index b70d274..84c144a 100644 --- a/tests/test_stage4_promotion_write_contract.py +++ b/tests/test_stage4_promotion_write_contract.py @@ -51,7 +51,7 @@ def handler(request: httpx.Request) -> httpx.Response: results = [ ad_promotion.apply_vas(codes=["xl"], dry_run=True), ad_promotion.apply_vas_package(package_code="turbo", dry_run=True), - ad_promotion.apply_vas_v2(codes=["highlight"], dry_run=True), + ad_promotion.apply_vas_direct(codes=["highlight"], dry_run=True), bbip.create_order( items=[BbipOrderItem(item_id=101, duration=7, price=1000, old_price=1200)], dry_run=True, @@ -145,7 +145,7 @@ def handler(request: httpx.Request) -> httpx.Response: vas_preview = ad_promotion.apply_vas(codes=["xl"], dry_run=True) package_preview = ad_promotion.apply_vas_package(package_code="turbo", dry_run=True) - vas_v2_preview = ad_promotion.apply_vas_v2(codes=["highlight"], dry_run=True) + vas_v2_preview = ad_promotion.apply_vas_direct(codes=["highlight"], dry_run=True) bbip_preview = bbip.create_order( items=[BbipOrderItem(item_id=101, duration=7, price=1000, old_price=1200)], dry_run=True, @@ -171,7 +171,7 @@ def handler(request: httpx.Request) -> httpx.Response: vas_apply = ad_promotion.apply_vas(codes=["xl"]) package_apply = ad_promotion.apply_vas_package(package_code="turbo") - vas_v2_apply = ad_promotion.apply_vas_v2(codes=["highlight"]) + vas_v2_apply = ad_promotion.apply_vas_direct(codes=["highlight"]) bbip_apply = bbip.create_order( items=[BbipOrderItem(item_id=101, duration=7, price=1000, old_price=1200)] ) diff --git a/tests/test_stage6_promotion.py b/tests/test_stage6_promotion.py index 50f96e8..4d352ab 100644 --- a/tests/test_stage6_promotion.py +++ b/tests/test_stage6_promotion.py @@ -22,6 +22,9 @@ TrxPromotionApplyItem, ) from avito.promotion.models import ( + CampaignListFilter, + CampaignOrderBy, + CampaignUpdateTimeFilter, CreateAutostrategyBudgetRequest, CreateAutostrategyCampaignRequest, ListAutostrategyCampaignsRequest, @@ -315,11 +318,16 @@ def handler(request: httpx.Request) -> httpx.Response: path = request.url.path payload = json.loads(request.content.decode()) if request.content else None if path == "/autostrategy/v1/budget": - assert payload == {"listingFee": 1000} + assert payload == { + "campaignType": "AS", + "startTime": "2026-04-20T00:00:00Z", + "finishTime": "2026-04-27T00:00:00Z", + "items": [101, 102], + } return httpx.Response( 200, json={ - "budgetId": "budget-1", + "calcId": 501, "budget": { "recommended": { "total": 10100, @@ -342,11 +350,25 @@ def handler(request: httpx.Request) -> httpx.Response: }, ) if path == "/autostrategy/v1/campaign/create": - assert payload == {"title": "Весенняя кампания", "budgetId": "budget-1"} - return httpx.Response(200, json={"campaignId": 77, "status": "created"}) + assert payload == { + "campaignType": "AS", + "title": "Весенняя кампания", + "budget": 10000, + "calcId": 501, + "items": [101, 102], + "startTime": "2026-04-20T00:00:00Z", + "finishTime": "2026-04-27T00:00:00Z", + } + return httpx.Response( + 200, + json={"campaign": {"campaignId": 77, "campaignType": "AS", "version": 3}}, + ) if path == "/autostrategy/v1/campaign/edit": - assert payload == {"campaignId": 77, "title": "Обновленная кампания"} - return httpx.Response(200, json={"campaignId": 77, "status": "updated"}) + assert payload == {"campaignId": 77, "version": 3, "title": "Обновленная кампания"} + return httpx.Response( + 200, + json={"campaign": {"campaignId": 77, "campaignType": "AS", "version": 4}}, + ) if path == "/autostrategy/v1/campaign/info": assert payload == {"campaignId": 77} return httpx.Response( @@ -355,63 +377,124 @@ def handler(request: httpx.Request) -> httpx.Response: "campaign": { "campaignId": 77, "campaignType": "AS", - "status": "active", + "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}], }, ) if path == "/autostrategy/v1/campaign/stop": - assert payload == {"campaignId": 77} - return httpx.Response(200, json={"campaignId": 77, "status": "stopped"}) + assert payload == {"campaignId": 77, "version": 4} + return httpx.Response( + 200, + json={"campaign": {"campaignId": 77, "campaignType": "AS", "version": 5}}, + ) if path == "/autostrategy/v1/campaigns": - assert payload == {"status": "active"} + assert payload == { + "limit": 20, + "offset": 10, + "statusId": [1, 2], + "orderBy": [{"column": "startTime", "direction": "asc"}], + "filter": { + "byUpdateTime": { + "from": "2026-04-01T00:00:00Z", + "to": "2026-04-30T00:00:00Z", + } + }, + } return httpx.Response( 200, json={ - "items": [ + "campaigns": [ { "campaignId": 77, "campaignType": "AS", - "status": "active", + "statusId": 1, "budget": 10000, } - ] + ], + "totalCount": 1, }, ) assert path == "/autostrategy/v1/stat" assert payload == {"campaignId": 77} return httpx.Response( - 200, json={"stat": {"campaignId": 77, "views": 500, "contacts": 30, "spend": 4500}} + 200, + json={ + "stat": [ + { + "date": "2026-04-18", + "calls": 30, + "views": 500, + "callsForecast": {"from": 25, "to": 35}, + "viewsForecast": {"from": 450, "to": 550}, + } + ], + "totals": {"calls": 30, "views": 500}, + }, ) campaign = AutostrategyCampaign(make_transport(httpx.MockTransport(handler)), resource_id=77) budget = campaign.create_budget( - request=CreateAutostrategyBudgetRequest(payload={"listingFee": 1000}) + request=CreateAutostrategyBudgetRequest( + campaign_type="AS", + start_time="2026-04-20T00:00:00Z", + finish_time="2026-04-27T00:00:00Z", + items=[101, 102], + ) ) created = campaign.create( request=CreateAutostrategyCampaignRequest( - payload={"title": "Весенняя кампания", "budgetId": "budget-1"} + campaign_type="AS", + title="Весенняя кампания", + budget=10000, + calc_id=501, + items=[101, 102], + start_time="2026-04-20T00:00:00Z", + finish_time="2026-04-27T00:00:00Z", ) ) updated = campaign.update( request=UpdateAutostrategyCampaignRequest( - payload={"campaignId": 77, "title": "Обновленная кампания"} + campaign_id=77, + version=3, + title="Обновленная кампания", ) ) info = campaign.get() - stopped = campaign.delete() + stopped = campaign.delete(version=4) campaigns = campaign.list( - request=ListAutostrategyCampaignsRequest(payload={"status": "active"}) + request=ListAutostrategyCampaignsRequest( + limit=20, + offset=10, + status_id=[1, 2], + order_by=[CampaignOrderBy(column="startTime", direction="asc")], + filter=CampaignListFilter( + by_update_time=CampaignUpdateTimeFilter( + from_time="2026-04-01T00:00:00Z", + to_time="2026-04-30T00:00:00Z", + ) + ), + ) ) stat = campaign.get_stat() - assert budget.budget_id == "budget-1" + assert budget.calc_id == 501 assert budget.recommended is not None and budget.recommended.total == 10100 - assert created.status == "created" - assert updated.status == "updated" - assert info.balance == 9000 - assert stopped.status == "stopped" + assert created.campaign is not None and created.campaign.version == 3 + assert updated.campaign is not None and updated.campaign.version == 4 + assert info.campaign is not None and info.campaign.balance == 9000 + assert info.items[0].item_id == 101 + assert stopped.campaign is not None and stopped.campaign.version == 5 assert campaigns.items[0].campaign_id == 77 - assert stat.spend == 4500 + assert campaigns.total_count == 1 + assert stat.totals is not None and stat.totals.views == 500 + assert stat.items[0].calls == 30 diff --git a/tests/test_stage7_orders.py b/tests/test_stage7_orders.py index 072cf41..84f18bf 100644 --- a/tests/test_stage7_orders.py +++ b/tests/test_stage7_orders.py @@ -574,7 +574,7 @@ def handler(request: httpx.Request) -> httpx.Response: ] ), ) - tariff = sandbox.add_tariff_v2( + tariff = sandbox.add_tariff( request=AddTariffV2Request( name="Tariff", delivery_provider_tariff_id="tariff-1", @@ -611,7 +611,7 @@ def handler(request: httpx.Request) -> httpx.Response: ], ) ) - cancelled_announcement_v1 = sandbox.cancel_announcement_v1( + cancelled_announcement_v1 = sandbox.cancel_sandbox_announcement( request=SandboxCancelAnnouncementRequest( announcement_id="ann-1", date="2026-04-20T10:00:00Z", @@ -620,13 +620,13 @@ def handler(request: httpx.Request) -> httpx.Response: ), ) ) - cancelled_parcel_v1 = sandbox.cancel_parcel_v1( + cancelled_parcel_v1 = sandbox.cancel_sandbox_parcel( request=CancelSandboxParcelRequest(parcel_id="spar-1") ) - changed_parcel_v1 = sandbox.change_parcel_v1( + changed_parcel_v1 = sandbox.change_sandbox_parcel( request=ChangeParcelRequest(type="changeReceiver", parcel_id="spar-1") ) - created_announcement_v1 = sandbox.create_announcement_v1( + created_announcement_v1 = sandbox.create_sandbox_announcement( request=SandboxCreateAnnouncementRequest( announcement_id="ann-1", barcode="barcode-1", @@ -658,16 +658,16 @@ def handler(request: httpx.Request) -> httpx.Response: ), ) ) - event_v1 = sandbox.get_announcement_event_v1( + event_v1 = sandbox.get_sandbox_announcement_event( request=SandboxGetAnnouncementEventRequest(announcement_id="ann-1") ) - change_info_v1 = sandbox.get_change_parcel_info_v1( + change_info_v1 = sandbox.get_sandbox_change_parcel_info( request=GetChangeParcelInfoRequest(application_id="app-1") ) - parcel_info_v1 = sandbox.get_parcel_info_v1( + parcel_info_v1 = sandbox.get_sandbox_parcel_info( request=GetSandboxParcelInfoRequest(parcel_id="spar-1") ) - registered_parcel_id_v1 = sandbox.get_registered_parcel_id_v1( + registered_parcel_id_v1 = sandbox.get_sandbox_registered_parcel_id( request=GetRegisteredParcelIdRequest(order_id="sand-1") ) sandbox_parcel = sandbox.create_parcel( diff --git a/tests/test_stage9_cpa.py b/tests/test_stage9_cpa.py index 403ba59..ff03363 100644 --- a/tests/test_stage9_cpa.py +++ b/tests/test_stage9_cpa.py @@ -9,7 +9,7 @@ from avito.core import Transport from avito.core.retries import RetryPolicy from avito.core.types import ApiTimeouts -from avito.cpa import CallTrackingCall, CpaCall, CpaChat, CpaLead, CpaLegacy +from avito.cpa import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead from avito.cpa.models import ( CallTrackingCallsRequest, CpaCallByIdRequest, @@ -136,7 +136,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert phones.items[1].phone_number == "+79990000002" -def test_cpa_calls_balance_and_legacy_flows() -> None: +def test_cpa_calls_balance_and_archive_flows() -> None: audio_bytes = b"ID3 fake audio" def handler(request: httpx.Request) -> httpx.Response: @@ -214,7 +214,7 @@ def handler(request: httpx.Request) -> httpx.Response: transport = make_transport(httpx.MockTransport(handler)) cpa_call = CpaCall(transport, resource_id="2001") cpa_lead = CpaLead(transport, resource_id="act-1") - legacy = CpaLegacy(transport, resource_id="2001") + archive = CpaArchive(transport, resource_id="2001") calls = cpa_call.list( request=CpaCallsByTimeRequest( @@ -228,10 +228,10 @@ def handler(request: httpx.Request) -> httpx.Response: complaint_by_action = cpa_lead.create_complaint_by_action_id( request=CpaLeadComplaintRequest(action_id="act-1", reason="duplicate") ) - balance_v3 = cpa_lead.create_balance_info_v3() - balance_v2 = legacy.get_balance_info_v2() - call_v2 = legacy.get_call_by_id_v2(request=CpaCallByIdRequest(call_id=2001)) - record = legacy.get_call() + balance_v3 = cpa_lead.get_balance_info() + balance_v2 = archive.get_balance_info() + call_v2 = archive.get_call_by_id(request=CpaCallByIdRequest(call_id=2001)) + record = archive.get_call() assert calls.items[0].record_url == "https://example.com/record-2001.mp3" assert complaint.success is True From d7ea66fc000c96c0c67563f15e0309758e194a3a Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Sun, 19 Apr 2026 16:43:18 +0300 Subject: [PATCH 05/17] =?UTF-8?q?=D0=98=D0=B4=D1=83=20=D0=BA=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B3=D0=BE=D0=BC=D1=83=20=D0=BE=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B0=D0=BD=D0=B8=D1=8E=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + README.md | 29 +++++++++++++++++++-------- avito/autoteka/client.py | 2 +- avito/autoteka/domain.py | 20 ++++++++++++------- avito/client/client.py | 36 ++++++++++++++++++++++++++-------- tests/test_public_api_shape.py | 9 +++++++-- tests/test_readme_examples.py | 15 ++++++++++++++ tests/test_stage10_autoteka.py | 10 ++++------ 8 files changed, 90 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6709939..a3f5196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ ### Изменено - `README.md` приведён к объектному API SDK и дополнен сценарными примерами по доменам. +- `autoteka` очищена от неканоничных public methods: `resolve_catalog()`, `list_reports()`, `delete_bucket()` и `remove_bucket()` заменили старые составные имена без compatibility alias-ов. - `pyproject.toml` дополнен strict-конфигурацией `mypy`, правилами `ruff` и современным build backend `poetry-core`. - `Makefile` разделён на `fmt`, `lint`, `typecheck`, `test`, `build` и `check`, чтобы release не зависел от автоформатирования. - `AGENTS.md` синхронизирован с реальной структурой SDK, наличием тестов и Python `3.14` quality gate. diff --git a/README.md b/README.md index a0666df..49b511b 100644 --- a/README.md +++ b/README.md @@ -183,11 +183,13 @@ print(campaigns.total_count) ```python from avito import AvitoClient +from avito.orders import OrderLabelsRequest, StockInfoRequest with AvitoClient() as avito: - order = avito.order(order_id=100500).get() - label = avito.order_label(task_id="task-1").download() - sandbox = avito.sandbox_delivery(task_id="task-1").get() + orders = avito.order().list() + label_task = avito.order_label().create(request=OrderLabelsRequest(order_ids=["100500"])) + label_pdf = avito.order_label(task_id=label_task.task_id).download() + stock_info = avito.stock().get(request=StockInfoRequest(item_ids=[100500])) ``` ### Работа @@ -209,10 +211,17 @@ with AvitoClient() as avito: ```python from avito import AvitoClient +from avito.cpa import CpaCallsByTimeRequest with AvitoClient() as avito: - calls = avito.cpa_call().list() - records = avito.call_tracking_call(call_id=10).download() + calls = avito.cpa_call().list( + request=CpaCallsByTimeRequest( + date_time_from="2026-04-18T00:00:00Z", + date_time_to="2026-04-19T00:00:00Z", + ) + ) + calltracking = avito.call_tracking_call(10).get() + records = avito.call_tracking_call(10).download() ``` ## Пагинация @@ -243,15 +252,19 @@ with AvitoClient() as avito: ```python from avito import AvitoClient -from avito.autoteka import PreviewReportRequest, VinRequest +from avito.autoteka import CatalogResolveRequest, PreviewReportRequest, VinRequest with AvitoClient() as avito: + catalog = avito.autoteka_vehicle().resolve_catalog( + request=CatalogResolveRequest(brand_id=1) + ) preview = avito.autoteka_vehicle().create_preview_by_vin( request=VinRequest(vin="XTA00000000000000") ) report = avito.autoteka_report().create_report( request=PreviewReportRequest(preview_id=int(preview.preview_id or 0)) ) + reports = avito.autoteka_report().list_reports() ``` ### Недвижимость, отзывы и тарифы @@ -261,12 +274,12 @@ from avito import AvitoClient from avito.realty import RealtyBookingsUpdateRequest, RealtyPricePeriod, RealtyPricesUpdateRequest with AvitoClient() as avito: - booking = avito.realty_booking(item_id=20, user_id=10) + booking = avito.realty_booking(20, user_id=10) booking.update_bookings_info( request=RealtyBookingsUpdateRequest(blocked_dates=["2026-05-01"]) ) bookings = booking.list_realty_bookings(date_start="2026-05-01", date_end="2026-05-05") - avito.realty_pricing(item_id=20, user_id=10).update_realty_prices( + avito.realty_pricing(20, user_id=10).update_realty_prices( request=RealtyPricesUpdateRequest( periods=[RealtyPricePeriod(date_from="2026-05-01", price=5000)] ) diff --git a/avito/autoteka/client.py b/avito/autoteka/client.py index f3ad9a1..9968ace 100644 --- a/avito/autoteka/client.py +++ b/avito/autoteka/client.py @@ -71,7 +71,7 @@ def _context(self, operation_name: str, *, allow_retry: bool = False) -> Request class CatalogClient(AutotekaBaseClient): """Выполняет HTTP-операции автокаталога.""" - def get_catalogs_resolve(self, request: CatalogResolveRequest) -> CatalogResolveResult: + def resolve_catalog(self, request: CatalogResolveRequest) -> CatalogResolveResult: payload = self.transport.request_json( "POST", "/autoteka/v1/catalogs/resolve", diff --git a/avito/autoteka/domain.py b/avito/autoteka/domain.py index a3dbb76..7273ec6 100644 --- a/avito/autoteka/domain.py +++ b/avito/autoteka/domain.py @@ -59,8 +59,10 @@ class AutotekaVehicle(DomainObject): resource_id: int | str | None = None user_id: int | str | None = None - def get_catalogs_resolve(self, *, request: CatalogResolveRequest) -> CatalogResolveResult: - return CatalogClient(self.transport).get_catalogs_resolve(request) + def resolve_catalog(self, *, request: CatalogResolveRequest) -> CatalogResolveResult: + """Актуализирует параметры автокаталога.""" + + return CatalogClient(self.transport).resolve_catalog(request) def get_leads(self, *, request: LeadsRequest) -> AutotekaLeadsResult: return LeadsClient(self.transport).get_leads(request) @@ -133,7 +135,9 @@ def create_report(self, *, request: PreviewReportRequest) -> AutotekaReportInfo: def create_report_by_vehicle_id(self, *, request: VehicleIdRequest) -> AutotekaReportInfo: return ReportClient(self.transport).create_report_by_vehicle_id(request) - def list_report_list(self) -> AutotekaReportsResult: + def list_reports(self) -> AutotekaReportsResult: + """Получает список отчетов Автотеки.""" + return ReportClient(self.transport).list_reports() def get_report(self, *, report_id: int | str | None = None) -> AutotekaReportInfo: @@ -165,12 +169,14 @@ def create_monitoring_bucket_add( ) -> MonitoringBucketResult: return MonitoringClient(self.transport).add_bucket(request) - def list_monitoring_bucket_delete(self) -> MonitoringBucketResult: + def delete_bucket(self) -> MonitoringBucketResult: + """Очищает bucket мониторинга.""" + return MonitoringClient(self.transport).delete_bucket() - def delete_monitoring_bucket_remove( - self, *, request: MonitoringBucketRequest - ) -> MonitoringBucketResult: + def remove_bucket(self, *, request: MonitoringBucketRequest) -> MonitoringBucketResult: + """Удаляет автомобили из bucket мониторинга.""" + return MonitoringClient(self.transport).remove_bucket(request) def get_monitoring_reg_actions( diff --git a/avito/client/client.py b/avito/client/client.py index 7b9887f..b2b0dac 100644 --- a/avito/client/client.py +++ b/avito/client/client.py @@ -321,25 +321,45 @@ def autoteka_valuation(self, valuation_id: int | str | None = None) -> AutotekaV return AutotekaValuation(self.transport, resource_id=valuation_id) - def realty_listing(self, item_id: int | str | None = None) -> RealtyListing: + def realty_listing( + self, + item_id: int | str | None = None, + *, + user_id: int | str | None = None, + ) -> RealtyListing: """Создает доменный объект объявления недвижимости.""" - return RealtyListing(self.transport, resource_id=item_id) + return RealtyListing(self.transport, resource_id=item_id, user_id=user_id) - def realty_booking(self, booking_id: int | str | None = None) -> RealtyBooking: + def realty_booking( + self, + item_id: int | str | None = None, + *, + user_id: int | str | None = None, + ) -> RealtyBooking: """Создает доменный объект бронирования недвижимости.""" - return RealtyBooking(self.transport, resource_id=booking_id) + return RealtyBooking(self.transport, resource_id=item_id, user_id=user_id) - def realty_pricing(self, item_id: int | str | None = None) -> RealtyPricing: + def realty_pricing( + self, + item_id: int | str | None = None, + *, + user_id: int | str | None = None, + ) -> RealtyPricing: """Создает доменный объект цен недвижимости.""" - return RealtyPricing(self.transport, resource_id=item_id) + return RealtyPricing(self.transport, resource_id=item_id, user_id=user_id) - def realty_analytics_report(self, report_id: int | str | None = None) -> RealtyAnalyticsReport: + def realty_analytics_report( + self, + item_id: int | str | None = None, + *, + user_id: int | str | None = None, + ) -> RealtyAnalyticsReport: """Создает доменный объект аналитического отчета недвижимости.""" - return RealtyAnalyticsReport(self.transport, resource_id=report_id) + return RealtyAnalyticsReport(self.transport, resource_id=item_id, user_id=user_id) def review(self, review_id: int | str | None = None) -> Review: """Создает доменный объект отзыва.""" diff --git a/tests/test_public_api_shape.py b/tests/test_public_api_shape.py index ac67011..9e03ebb 100644 --- a/tests/test_public_api_shape.py +++ b/tests/test_public_api_shape.py @@ -122,6 +122,8 @@ def test_public_surface_does_not_expose_legacy_or_version_suffixed_method_names( "avito.client.client", "avito.auth.provider", "avito.ads.domain", + "avito.autoteka.client", + "avito.autoteka.domain", "avito.cpa.domain", "avito.cpa.client", "avito.jobs.domain", @@ -133,6 +135,7 @@ def test_public_surface_does_not_expose_legacy_or_version_suffixed_method_names( ) banned_fragments = ("legacy_",) banned_suffixes = ("_v1", "_v2") + banned_prefixes = ("get_catalogs_", "list_report_", "list_monitoring_", "delete_monitoring_") offenders: list[str] = [] for module_name in module_names: @@ -143,8 +146,10 @@ def test_public_surface_does_not_expose_legacy_or_version_suffixed_method_names( for method_name, method in inspect.getmembers(cls, inspect.isfunction): if method_name.startswith("_"): continue - if any(fragment in method_name for fragment in banned_fragments) or method_name.endswith( - banned_suffixes + if ( + any(fragment in method_name for fragment in banned_fragments) + or method_name.endswith(banned_suffixes) + or method_name.startswith(banned_prefixes) ): offenders.append(f"{module_name}.{cls.__name__}.{method_name}{inspect.signature(method)}") diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py index bc26829..8ad6294 100644 --- a/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -3,6 +3,8 @@ import inspect from avito import AvitoClient +from avito.autoteka import AutotekaMonitoring, AutotekaReport, AutotekaVehicle +from avito.autoteka.models import CatalogResolveRequest, MonitoringBucketRequest from avito.promotion.models import ( CampaignListFilter, CampaignOrderBy, @@ -55,9 +57,22 @@ def test_readme_references_current_public_method_names() -> None: assert hasattr(AvitoClient, "autoload_archive") assert hasattr(AvitoClient, "cpa_archive") assert hasattr(Review, "list") + assert hasattr(AutotekaVehicle, "resolve_catalog") + assert hasattr(AutotekaReport, "list_reports") + assert hasattr(AutotekaMonitoring, "delete_bucket") + assert hasattr(AutotekaMonitoring, "remove_bucket") assert not hasattr(AvitoClient, "autoload_legacy") assert not hasattr(AvitoClient, "cpa_legacy") assert not hasattr(Review, "list_reviews_v1") + assert not hasattr(AutotekaVehicle, "get_catalogs_resolve") + assert not hasattr(AutotekaReport, "list_report_list") + assert not hasattr(AutotekaMonitoring, "list_monitoring_bucket_delete") + assert not hasattr(AutotekaMonitoring, "delete_monitoring_bucket_remove") review_signature = str(inspect.signature(Review.list)) assert "query" in review_signature + + +def test_readme_uses_current_autoteka_request_models() -> None: + assert CatalogResolveRequest(brand_id=1).to_payload() == {"brandId": 1} + assert MonitoringBucketRequest(vehicles=["VIN-1"]).to_payload() == {"vehicles": ["VIN-1"]} diff --git a/tests/test_stage10_autoteka.py b/tests/test_stage10_autoteka.py index 2667217..42a833c 100644 --- a/tests/test_stage10_autoteka.py +++ b/tests/test_stage10_autoteka.py @@ -155,7 +155,7 @@ def handler(request: httpx.Request) -> httpx.Response: vehicle = AutotekaVehicle(make_transport(httpx.MockTransport(handler)), resource_id="77") - catalog = vehicle.get_catalogs_resolve(request=CatalogResolveRequest(brand_id=1)) + catalog = vehicle.resolve_catalog(request=CatalogResolveRequest(brand_id=1)) leads = vehicle.get_leads(request=LeadsRequest(limit=1)) preview_vin = vehicle.create_preview_by_vin(request=VinRequest(vin="VIN-1")) preview_item = vehicle.create_preview_by_item_id(request=ItemIdRequest(item_id=901)) @@ -346,7 +346,7 @@ def handler(request: httpx.Request) -> httpx.Response: created_by_vehicle = report.create_report_by_vehicle_id( request=VehicleIdRequest(vehicle_id="VIN-1") ) - reports = report.list_report_list() + reports = report.list_reports() fetched = report.get_report() sync_reg = report.create_sync_report_by_reg_number( request=RegNumberRequest(reg_number="A123AA77") @@ -355,10 +355,8 @@ def handler(request: httpx.Request) -> httpx.Response: added = monitoring.create_monitoring_bucket_add( request=MonitoringBucketRequest(vehicles=["VIN-1", "bad-vin"]) ) - deleted = monitoring.list_monitoring_bucket_delete() - removed = monitoring.delete_monitoring_bucket_remove( - request=MonitoringBucketRequest(vehicles=["VIN-1"]) - ) + deleted = monitoring.delete_bucket() + removed = monitoring.remove_bucket(request=MonitoringBucketRequest(vehicles=["VIN-1"])) events = monitoring.get_monitoring_reg_actions(query=MonitoringEventsQuery(limit=10)) scoring_created = scoring.create_scoring_by_vehicle_id( request=VehicleIdRequest(vehicle_id="VIN-1") From 3f1981f464820a17f6a47e386e7ce361410846ba Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Sun, 19 Apr 2026 17:30:20 +0300 Subject: [PATCH 06/17] =?UTF-8?q?=D0=98=D0=B4=D1=83=20=D0=BA=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B3=D0=BE=D0=BC=D1=83=20=D0=BE=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B0=D0=BD=D0=B8=D1=8E=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 11 + CHANGELOG.md | 37 - README.md | 2 +- STYLEGUIDE.md | 2 +- TODO.md | 835 ++++++-------------- avito/accounts/enums.py | 1 - avito/ads/domain.py | 9 +- avito/ads/enums.py | 1 - avito/ads/models.py | 24 +- avito/autoteka/enums.py | 1 - avito/cpa/enums.py | 1 - avito/jobs/enums.py | 1 - avito/messenger/enums.py | 1 - avito/orders/enums.py | 1 - avito/promotion/domain.py | 9 +- avito/promotion/enums.py | 1 - avito/promotion/models.py | 14 +- avito/ratings/enums.py | 1 - avito/realty/enums.py | 1 - avito/tariffs/enums.py | 1 - tests/test_readme_examples.py | 19 + tests/test_stage8_serialization_contract.py | 48 ++ 22 files changed, 343 insertions(+), 678 deletions(-) create mode 100644 .claude/settings.local.json delete mode 100644 CHANGELOG.md delete mode 100644 avito/accounts/enums.py delete mode 100644 avito/ads/enums.py delete mode 100644 avito/autoteka/enums.py delete mode 100644 avito/cpa/enums.py delete mode 100644 avito/jobs/enums.py delete mode 100644 avito/messenger/enums.py delete mode 100644 avito/orders/enums.py delete mode 100644 avito/promotion/enums.py delete mode 100644 avito/ratings/enums.py delete mode 100644 avito/realty/enums.py delete mode 100644 avito/tariffs/enums.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..9306643 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 *)", + "Bash(find *)", + "Bash(make typecheck *)", + "Bash(make test *)", + "Bash(make check *)" + ] + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index a3f5196..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,37 +0,0 @@ -# Changelog - -Все заметные изменения SDK фиксируются в этом файле. - -Формат записей: - -- `Добавлено` — новые публичные методы, модели и доменные блоки. -- `Изменено` — совместимые изменения поведения и документации. -- `Исправлено` — багфиксы, корректировки mapping и transport. -- `Удалено` — убранные или больше не поддерживаемые части API. - -## [Unreleased] - -### Добавлено - -- Политика релизного процесса в `docs/release.md`. -- Проверки качества `pytest`, `mypy`, `ruff` и `poetry build` в `README.md`. -- Безопасный debug hook `AvitoClient.debug_info()` для диагностики transport-конфигурации. -- GitHub Actions workflows `CI` и `Release` с обязательной валидацией проекта на Python `3.14`. -- Автоматическая публикация релиза по тегу `v*` с проверкой соответствия версии из `pyproject.toml`. -- Единый quality gate `make check` для тестов, типизации, линтинга и сборки. - -### Изменено - -- `README.md` приведён к объектному API SDK и дополнен сценарными примерами по доменам. -- `autoteka` очищена от неканоничных public methods: `resolve_catalog()`, `list_reports()`, `delete_bucket()` и `remove_bucket()` заменили старые составные имена без compatibility alias-ов. -- `pyproject.toml` дополнен strict-конфигурацией `mypy`, правилами `ruff` и современным build backend `poetry-core`. -- `Makefile` разделён на `fmt`, `lint`, `typecheck`, `test`, `build` и `check`, чтобы release не зависел от автоформатирования. -- `AGENTS.md` синхронизирован с реальной структурой SDK, наличием тестов и Python `3.14` quality gate. -- `realty` больше не использует generic `RealtyRequest`: публичные методы принимают `RealtyBookingsUpdateRequest`, `RealtyPricesUpdateRequest`, `RealtyIntervalsRequest` и `RealtyBaseParamsUpdateRequest`. -- `jobs` больше не использует generic `JobsRequest` / `JobsQuery`: публичный surface переведен на отдельные typed request/query-модели для applications, vacancies, resumes и webhooks. -- `autoteka` больше не использует generic `AutotekaRequest` / `AutotekaQuery`: публичный surface переведен на отдельные typed request/query-модели для preview, report, monitoring, scoring и valuation сценариев. -- `messenger.ChatMedia.upload_images()` больше не принимает `dict[str, object]`; вместо него используется typed request через `UploadImageFile` / `UploadImagesRequest`. -- `promotion.autostrategy` больше не использует generic payload-wrapper’ы: `CreateAutostrategyBudgetRequest`, `CreateAutostrategyCampaignRequest`, `UpdateAutostrategyCampaignRequest` и `ListAutostrategyCampaignsRequest` теперь содержат typed поля по documented contract. -- `promotion` и `ads` write-клиенты больше не раскрывают `Mapping[str, object]` в публичных сигнатурах helper/client-слоя; preview и apply используют одинаковый typed request contract. -- Автостратегия приведена к documented shape ответов: бюджет теперь возвращает `calc_id`, список кампаний включает `total_count`, `CampaignDetailsResult` хранит `campaign` / `forecast` / `items`, а `AutostrategyStat` содержит ежедневные значения и `totals`. -- Публичный surface очищен от неканоничных имен: `autoload_legacy()` -> `autoload_archive()`, `cpa_legacy()` -> `cpa_archive()`, `apply_vas_v2()` -> `apply_vas_direct()`, version-suffixed методы ratings/realty/orders заменены на каноничные имена без `_v1` / `_v2`. diff --git a/README.md b/README.md index 49b511b..e17138b 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ client = AvitoClient.from_env() - `AVITO_AUTH__REFRESH_TOKEN`, alias: `AVITO_REFRESH_TOKEN`, `REFRESH_TOKEN` - `AVITO_AUTH__SCOPE`, alias: `AVITO_SCOPE`, `SCOPE` - `AVITO_AUTH__TOKEN_URL`, alias: `AVITO_TOKEN_URL`, `TOKEN_URL` -- `AVITO_AUTH__LEGACY_TOKEN_URL`, alias: `AVITO_LEGACY_TOKEN_URL`, `LEGACY_TOKEN_URL` +- `AVITO_AUTH__ALTERNATE_TOKEN_URL`, alias: `AVITO_ALTERNATE_TOKEN_URL`, `ALTERNATE_TOKEN_URL` - `AVITO_AUTH__AUTOTEKA_TOKEN_URL`, alias: `AVITO_AUTOTEKA_TOKEN_URL`, `AUTOTEKA_TOKEN_URL` - `AVITO_AUTH__AUTOTEKA_CLIENT_ID`, alias: `AVITO_AUTOTEKA_CLIENT_ID`, `AUTOTEKA_CLIENT_ID` - `AVITO_AUTH__AUTOTEKA_CLIENT_SECRET`, alias: `AVITO_AUTOTEKA_CLIENT_SECRET`, `AUTOTEKA_CLIENT_SECRET` diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index 6452476..364ca54 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -375,7 +375,7 @@ Read-операции должны быть выровнены по форме - `bbip_promotion().create_order(...)` - `ad_promotion().apply_vas(...)` - `ad_promotion().apply_vas_package(...)` -- `ad_promotion().apply_vas_v2(...)` +- `ad_promotion().apply_vas_direct(...)` - `trx_promotion().apply(...)` - `trx_promotion().delete(...)` - `target_action_pricing().update_manual(...)` diff --git a/TODO.md b/TODO.md index 186a126..a4b83f0 100644 --- a/TODO.md +++ b/TODO.md @@ -1,609 +1,246 @@ -# TODO +# TODO: Исправления STYLEGUIDE и API-контракт -## Цель +## Context -Полностью реализовать SDK для всех методов, описанных в `docs/*.json`, с архитектурой, строго соответствующей `STYLEGUIDE.md`: +Полный анализ проекта выявил 7 категорий несоответствий между кодом, STYLEGUIDE.md и README.md. +Все изменения направлены на устранение конкретных нарушений; backward-compatibility не соблюдается. +Порядок шагов: сначала код, затем тесты, в конце документация. -- публичный API только через объектный фасад `AvitoClient`; -- один публичный клиент без дерева section clients в пользовательском API; -- отдельные слои `auth`, `core`, `domain objects`, `mappers`, `errors`; -- публичные ответы только как `@dataclass(slots=True, frozen=True)`; -- единый transport на `httpx.Client`; -- централизованные retries, timeouts, обработка `401`, `4xx`, `5xx`, rate limit; -- отсутствие `assert` и сырых `dict[str, Any]` в публичном API. -- пользовательская документация репозитория и docstring публичных сущностей должны быть на русском языке. +--- -Целевая форма пользовательского API: +## Шаг 1 — Исправить `Any` в domain-файлах + +**Нарушение:** STYLEGUIDE §Типизация: "`Any` запрещен, кроме узких boundary-layer мест с локальным объяснением". + +### 1.1 `avito/promotion/domain.py` + +| Строка | Было | Станет | +|--------|------|--------| +| 5 | `from typing import Any` | удалить | +| 62 | `items: Sequence[Any]` | `items: Sequence[object]` | +| 83 | `target: dict[str, Any]` | `target: dict[str, object]` | +| 84 | `request_payload: dict[str, Any]` | `request_payload: dict[str, object]` | + +### 1.2 `avito/ads/domain.py` + +| Строка | Было | Станет | +|--------|------|--------| +| 6 | `from typing import Any` | удалить | +| 49 | `items: Sequence[Any]` | `items: Sequence[object]` | +| 65 | `target: dict[str, Any]` | `target: dict[str, object]` | +| 66 | `request_payload: dict[str, Any]` | `request_payload: dict[str, object]` | + +**Тест:** `make typecheck` не должен выдавать `[assignment]` / `[arg-type]` по этим файлам. + +--- + +## Шаг 2 — Добавить `SerializableModel` публичным моделям в `avito/ads/models.py` + +**Нарушение:** STYLEGUIDE §Dataclass: "Каждая публичная модель должна предоставлять единообразную сериализацию через `to_dict()` и `model_dump()`." Классы ниже не наследуют `SerializableModel` — получают методы только через runtime-патч `enable_module_serialization`. При strict mypy вызов `.to_dict()` на них даст `[attr-defined]`. + +Добавить `(SerializableModel)` как базовый класс (без изменения полей): + +- `UpdatePriceResult` (строка 44) +- `ItemAnalyticsResult` (строка 133) +- `VasPricesResult` (строка 188) +- `VasApplyResult` (строка 195) +- `UploadResult` (строка 270) +- `AutoloadFieldsResult` (строка 288) +- `AutoloadTreeResult` (строка 304) +- `IdMappingResult` (строка 311) +- `AutoloadReportsResult` (строка 329) +- `AutoloadReportItemsResult` (строка 347) +- `AutoloadFeesResult` (строка 364) +- `ActionResult` (строка 392) + +**Тест:** добавить в `tests/test_stage8_serialization_contract.py`: + +```python +def test_ads_result_models_serialize_correctly() -> None: + from avito.ads.models import UpdatePriceResult, IdMappingResult + + r = UpdatePriceResult(item_id=42, price=999.0, status="active") + assert r.to_dict() == {"item_id": 42, "price": 999.0, "status": "active"} + json.dumps(r.to_dict()) + + m = IdMappingResult(items=[]) + assert m.to_dict() == {"items": []} +``` + +--- + +## Шаг 3 — Добавить `SerializableModel` публичным моделям в `avito/promotion/models.py` + +**Нарушение:** то же, что в шаге 2. + +Следующие классы возвращаются публичными методами `AutostrategyCampaign`, но не наследуют `SerializableModel`: + +- `AutostrategyBudgetPoint` (строка 566) +- `AutostrategyPriceRange` (строка 579) +- `AutostrategyBudget` (строка 592) — возвращается `autostrategy_campaign().create_budget()` +- `CampaignActionResult` (строка 625) — возвращается `create()`, `update()`, `delete()` +- `CampaignInfo` (строка 632) — вложена в `CampaignActionResult` и `CampaignsResult` +- `CampaignsResult` (строка 685) — возвращается `list()` +- `AutostrategyStat` (строка 693) — возвращается `get_stat()` + +Добавить `(SerializableModel)` как базовый класс. + +**Тест:** добавить в `tests/test_stage8_serialization_contract.py`: + +```python +def test_autostrategy_models_serialize_correctly() -> None: + from avito.promotion.models import ( + AutostrategyBudget, + CampaignActionResult, + CampaignsResult, + AutostrategyStat, + AutostrategyStatItem, + AutostrategyStatTotals, + ) + + budget = AutostrategyBudget( + calc_id=1, recommended=None, minimal=None, maximal=None, price_ranges=[] + ) + assert budget.to_dict() == { + "calc_id": 1, + "recommended": None, + "minimal": None, + "maximal": None, + "price_ranges": [], + } + json.dumps(budget.to_dict()) + + result = CampaignActionResult(campaign=None) + assert result.to_dict() == {"campaign": None} + + campaigns = CampaignsResult(items=[], total_count=0) + assert campaigns.to_dict() == {"items": [], "total_count": 0} + + stat = AutostrategyStat( + items=[AutostrategyStatItem(date="2026-01-01", calls=5, views=10)], + totals=AutostrategyStatTotals(calls=5, views=10), + ) + dumped = stat.to_dict() + assert dumped["totals"] == {"calls": 5, "views": 10} + json.dumps(dumped) +``` + +--- + +## Шаг 4 — Удалить пустые `enums.py` файлы + +**Нарушение:** STYLEGUIDE §Чего в проекте быть не должно: "устаревший код". Все перечисленные файлы содержат только docstring, не импортируются ни в одном модуле — мёртвый код. (`auth/enums.py` содержит константы и импортируется — его не трогать.) + +Удалить: +``` +avito/accounts/enums.py +avito/ads/enums.py +avito/autoteka/enums.py +avito/cpa/enums.py +avito/jobs/enums.py +avito/messenger/enums.py +avito/orders/enums.py +avito/promotion/enums.py +avito/ratings/enums.py +avito/realty/enums.py +avito/tariffs/enums.py +``` + +**Тест:** `make check` проходит без ошибок (lint, typecheck, test). + +--- + +## Шаг 5 — Исправить имя env-переменной в `README.md` + +**Нарушение:** README.md строка 81 документирует несуществующую переменную. + +``` +# Было: +- `AVITO_AUTH__LEGACY_TOKEN_URL`, alias: `AVITO_LEGACY_TOKEN_URL`, `LEGACY_TOKEN_URL` + +# Станет: +- `AVITO_AUTH__ALTERNATE_TOKEN_URL`, alias: `AVITO_ALTERNATE_TOKEN_URL`, `ALTERNATE_TOKEN_URL` +``` + +**Тест:** добавить в `tests/test_readme_examples.py`: + +```python +def test_auth_settings_env_var_names_match_readme() -> None: + from avito.auth.settings import AuthSettings + + supported = AuthSettings.supported_env_vars() + alternate_aliases = supported.get("alternate_token_url", ()) + + assert "AVITO_AUTH__ALTERNATE_TOKEN_URL" in alternate_aliases + assert "AVITO_ALTERNATE_TOKEN_URL" in alternate_aliases + assert "ALTERNATE_TOKEN_URL" in alternate_aliases + + all_aliases = {alias for aliases in supported.values() for alias in aliases} + assert not any("LEGACY" in a for a in all_aliases) +``` + +--- + +## Шаг 6 — Исправить STYLEGUIDE.md: `apply_vas_v2` → `apply_vas_direct` + +**Нарушение:** STYLEGUIDE.md строка 378 называет метод `apply_vas_v2()`, которого нет в коде. Метод переименован в `apply_vas_direct()`, но STYLEGUIDE не обновлён. + +``` +# STYLEGUIDE.md строка 378 +# Было: +- `ad_promotion().apply_vas_v2(...)` + +# Станет: +- `ad_promotion().apply_vas_direct(...)` +``` + +**Тест:** расширить `test_readme_references_current_public_method_names` в `tests/test_readme_examples.py`: ```python -avito = AvitoClient(...) -ad = avito.ad(item_id).get() -avito.account(user_id).get_balance() -avito.chat(chat_id, user_id=user_id).send_message(...) + from avito.ads.domain import AdPromotion + + assert hasattr(AdPromotion, "apply_vas_direct") + assert not hasattr(AdPromotion, "apply_vas_v2") +``` + +--- + +## Шаг 7 — Обновить CHANGELOG.md + +Добавить в раздел `Unreleased`: + +```markdown +- Документация: исправлено имя env-переменной в README (`LEGACY_TOKEN_URL` → `ALTERNATE_TOKEN_URL`) +- Документация: исправлено имя метода в STYLEGUIDE (`apply_vas_v2` → `apply_vas_direct`) +- Типизация: `Sequence[Any]` и `dict[str, Any]` заменены на `Sequence[object]` + и `dict[str, object]` в `ads/domain.py` и `promotion/domain.py` +- Типизация: публичные result-модели autostrategy и ads теперь явно наследуют + `SerializableModel` вместо runtime-патча +- Удалены 11 пустых `enums.py` файлов (accounts, ads, autoteka, cpa, jobs, messenger, + orders, promotion, ratings, realty, tariffs) ``` -Доменные объекты создаются через один клиент и инкапсулируют операции своей предметной области. В публичном API не должно быть сценариев вида `client.ads.get(...)`, `client.orders.list(...)` или `client.messenger.send(...)`. - -## Полный охват документации - -Нужно покрыть все разделы из `docs/`: - -- `auth`: Авторизация, а также отдельный токен в `Автотека`. -- `accounts`: Информация о пользователе, Иерархия аккаунтов. -- `ads`: Объявления, Автозагрузка. -- `messenger`: Мессенджер, Рассылка скидок и спецпредложений. -- `promotion`: Продвижение, TrxPromo, CPA-аукцион, Настройка цены целевого действия, Автостратегия. -- `orders`: Управление заказами, Доставка, Управление остатками. -- `jobs`: Авито.Работа. -- `cpa`: CPA Авито, CallTracking. -- `autoteka`: все операции по превью, отчетам, скорингу, мониторингу, тизерам, оценке. -- `realty`: Краткосрочная аренда, Аналитика по недвижимости. -- `ratings`: Рейтинги и отзывы. -- `tariffs`: Тарифы. - -Deprecated-методы из swagger не пропускать: реализовать как отдельные legacy-методы с явной пометкой в docstring и тестах. - -## Явная матрица покрытия docs -> SDK - -Ниже зафиксировано соответствие каждого документа и его endpoint-групп будущим пакетам SDK. Это обязательная часть плана, а не задача "на потом". - -| Документ | Операции | Пакет SDK | Доменный объект | Этап | -| --- | ---: | --- | --- | ---: | -| `docs/Авторизация.json` | 3 | `auth` | `AvitoClient.auth()` и внутренние token flow-объекты | 3 | -| `docs/Информацияопользователе.json` | 3 | `accounts` | `Account` | 4 | -| `docs/ИерархияАккаунтов.json` | 5 | `accounts` | `AccountHierarchy` | 4 | -| `docs/Объявления.json` | 11 | `ads` | `Ad`, `AdStats`, `AdPromotion` | 4 | -| `docs/Автозагрузка.json` | 17 | `ads` | `AutoloadProfile`, `AutoloadReport`, `AutoloadLegacy` | 4 | -| `docs/Мессенджер.json` | 13 | `messenger` | `Chat`, `ChatMessage`, `ChatWebhook`, `ChatMedia` | 5 | -| `docs/Рассылкаскидокиспецпредложенийвмессенджере.json` | 5 | `messenger` | `SpecialOfferCampaign` | 5 | -| `docs/Продвижение.json` | 7 | `promotion` | `PromotionOrder`, `BbipPromotion` | 6 | -| `docs/TrxPromo.json` | 3 | `promotion` | `TrxPromotion` | 6 | -| `docs/CPA-аукцион.json` | 2 | `promotion` | `CpaAuction` | 6 | -| `docs/Настройкаценыцелевогодействия.json` | 5 | `promotion` | `TargetActionPricing` | 6 | -| `docs/Автостратегия.json` | 7 | `promotion` | `AutostrategyCampaign` | 6 | -| `docs/Управлениезаказами.json` | 12 | `orders` | `Order`, `OrderLabel` | 7 | -| `docs/Доставка.json` | 31 | `orders` | `DeliveryOrder`, `SandboxDelivery`, `DeliveryTask` | 7 | -| `docs/Управлениеостатками.json` | 2 | `orders` | `Stock` | 7 | -| `docs/АвитоРабота.json` | 25 | `jobs` | `Vacancy`, `Application`, `Resume`, `JobWebhook`, `JobDictionary` | 8 | -| `docs/CPAАвито.json` | 11 | `cpa` | `CpaLead`, `CpaChat`, `CpaCall`, `CpaLegacy` | 9 | -| `docs/CallTracking[КТ].json` | 3 | `cpa` | `CallTrackingCall` | 9 | -| `docs/Автотека.json` | 27 | `autoteka` | `AutotekaVehicle`, `AutotekaReport`, `AutotekaMonitoring`, `AutotekaScoring`, `AutotekaValuation` | 10 | -| `docs/Краткосрочнаяаренда.json` | 5 | `realty` | `RealtyListing`, `RealtyBooking`, `RealtyPricing` | 11 | -| `docs/Аналитикапонедвижимости.json` | 2 | `realty` | `RealtyAnalyticsReport` | 11 | -| `docs/Рейтингииотзывы.json` | 4 | `ratings` | `Review`, `ReviewAnswer`, `RatingProfile` | 11 | -| `docs/Тарифы.json` | 1 | `tariffs` | `Tariff` | 11 | - -Правила полноты: - -- Для каждого endpoint из таблицы в `docs/inventory.md` должны быть зафиксированы: - - HTTP method; - - path; - - swagger summary; - - target package; - - target domain object; - - публичный метод SDK; - - признак `legacy/deprecated`; - - тип request/response модели; - - тесты, покрывающие endpoint. -- Этап считается закрытым только если все endpoints из соответствующих строк таблицы получили конкретные методы SDK и тесты. -- Любой endpoint из `docs/*.json`, который не попал в таблицу или в inventory, считается пропуском плана. - -## Проверка соответствия swagger - -Покрытие `docs/*.json` должно не только существовать в inventory, но и регулярно проверяться на консистентность. - -- Для каждого swagger-документа нужно зафиксировать число операций и их соответствие inventory. -- Для каждого endpoint нужно проверять совпадение: - - HTTP method; - - path; - - deprecated-статуса; - - основных request/response схем на уровне, достаточном для выявления расхождений SDK с документацией. -- Любое расхождение между swagger и `docs/inventory.md` считается дефектом плана и должно исправляться до расширения SDK. -- Если swagger содержит неоднозначность или явную ошибку, это должно быть отдельно отмечено в inventory с пояснением принятой нормализации. -- Для inventory и матрицы покрытия нужна отдельная проверка в тестах или validation script, которую можно запускать перед релизом. - -## Целевая структура пакетов - -```text -avito/ - __init__.py - client.py - config.py - auth/ - core/ - accounts/ - ads/ - messenger/ - promotion/ - orders/ - jobs/ - cpa/ - autoteka/ - realty/ - ratings/ - tariffs/ +--- + +## Шаг 8 — Финальная проверка + +```bash +make check # fmt + lint + typecheck + test + build ``` -В каждом доменном пакете: - -- `domain.py` с доменными объектами и их операциями; -- `models.py` с dataclass-моделями; -- `enums.py` для строковых констант API; -- `mappers.py` для JSON -> dataclass; -- при необходимости `requests.py` или `filters.py` для typed request-моделей. - -## Общие правила реализации - -- Все JSON-ответы сначала описывать как boundary-типы (`TypedDict`), затем маппить в dataclass. -- Для списков, пагинации, batch-операций и task-based API делать отдельные result-модели. -- Для бинарных ответов выделить типизированные low-level сущности: - - аудиозаписи звонков; - - PDF этикеток; - - загрузка изображений и файлов. -- Для webhook-операций сделать отдельные request/result модели и контрактные тесты сериализации. -- Для sandbox-методов `delivery` сохранить отдельный `SandboxDeliveryClient`, не смешивая с production API. -- Для `x-gateway`, rate-limiter и нестандартных заголовков добавить расширяемую конфигурацию transport, но скрыть детали от публичного API. -- Сохранять обратную совместимость с текущим кодом не требуется: при конфликте старой структуры с новой архитектурой приоритет всегда у новой архитектуры из `STYLEGUIDE.md`. -- Каждый публичный и внутренний класс должен иметь короткий обязательный docstring с описанием ответственности и контракта. -- Пользовательская документация репозитория, `docs/*.md`, README и docstring публичных сущностей ведутся на русском языке. -- Аномалии в swagger нормализовать в inventory: - - дубли `/token` с невидимыми символами; - - deprecated-версии; - - неоднородные path/query/body схемы; - - операции, возвращающие task id для последующего polling. - -## Этап 1. Инвентаризация swagger и каркас SDK - -Что сделать: - -- Создать `docs/inventory.md` с перечнем всех операций: section, HTTP method, path, swagger summary, статус `deprecated`, пакет SDK, domain object, публичный метод SDK, тип request, тип response, тип теста. -- Зафиксировать единый mapping `docs/*.json -> package/module`. -- Удалить текущую смесь логики из `avito/client/client.py` и заменить на один минимальный клиент, совместимый с новой архитектурой. -- Создать пакеты `auth`, `core` и доменные пакеты-заготовки. -- Ввести единый стиль именования фабрик и методов доменных объектов: - - `avito.account(...)`, `avito.ad(...)`, `avito.chat(...)`, `avito.order(...)`, `avito.tariff(...)`; - - `get_*`, `list_*`, `create_*`, `update_*`, `delete_*`, `apply_*`, `download_*`, `get_*_by_*`. - -Тесты: - -- Smoke-тест импорта `AvitoClient`. -- Тест структуры единого клиента: `avito.account(...)`, `avito.ad(...)`, `avito.chat(...)`, `avito.order(...)` и другие доменные фабрики доступны как методы одного клиента. -- Тест проверки соответствия `docs/inventory.md` исходным swagger-документам. - -Критерии готовности: - -- В репозитории есть полный inventory по всем swagger-операциям. -- Inventory доказывает соответствие `каждый endpoint -> конкретный метод SDK -> конкретный тест`. -- Новая пакетная структура создана. -- Старый god-object больше не является точкой дальнейшей разработки, а новый единый клиент является единственной публичной точкой входа. - -## Этап 2. Core: config, exceptions, transport, retries, pagination - -Что сделать: - -- Вынести настройки в `config.py` и `auth/settings.py` на `pydantic-settings`. -- Реализовать: - - `ApiTimeouts`; - - `RetryPolicy`; - - `Transport`; - - `RequestContext`; - - `Paginator` или page/result abstractions; - - иерархию исключений из `STYLEGUIDE.md`. -- Добавить нормализацию URL и кодирование path/query параметров. -- Реализовать базовую обработку: - - `401 -> controlled token refresh`; - - `429 -> RateLimitError` или retry по политике; - - `5xx -> retry + ServerError`; - - mapping errors -> `ResponseMappingError`. -- Поддержать JSON, multipart upload, binary download. - -Тесты: - -- Unit-тесты таймаутов, retry-решений и классификации ошибок. -- Тест refresh токена после `401` с повтором запроса. -- Тест, что неидемпотентные запросы не ретраятся без явного разрешения. -- Тест бинарного ответа и multipart upload. -- Тест пагинации и сборки типизированного page result. - -Критерии готовности: - -- Ни один доменный объект не делает прямой вызов `httpx` или `requests`. -- Все сетевые ошибки поднимаются как доменные исключения. -- Transport покрыт regression-тестами на основные ветки поведения. - -## Этап 3. Auth provider и аутентификация - -Что сделать: - -- Реализовать `AuthProvider` с кешем access token и временем жизни. -- Поддержать: - - client credentials; - - refresh token flow; - - отдельный auth flow для `Автотека`, если он отличается по контракту. -- Нормализовать дубли `/token` из swagger в один публичный API с legacy-alias при необходимости. -- Добавить low-level модели токенов и auth errors. - -Тесты: - -- Успешное получение access token. -- Успешный refresh token. -- Ошибка авторизации маппится в `AuthenticationError`. -- Дублирующиеся swagger-path не ломают публичный интерфейс. - -Критерии готовности: - -- Ни один API-метод не получает токен самостоятельно. -- Обновление токена происходит централизованно и прозрачно для доменных объектов. - -## Этап 4. Базовые account- и ads-разделы - -Покрыть разделы: - -- `Информация о пользователе` -- `Иерархия Аккаунтов` -- `Объявления` -- `Автозагрузка` - -Что сделать: - -- Реализовать доменные объекты `Account`, `AccountHierarchy`, `Ad`, `AdStats`, `AdPromotion`, `AutoloadProfile`, `AutoloadReport`. -- В `accounts` покрыть: - - `self`; - - `balance`; - - `operations_history`; - - статус пользователя в ИА; - - сотрудников; - - телефоны компании; - - связь объявлений и сотрудников; - - список объявлений сотрудника. -- В `ads` покрыть: - - получение одного объявления и списка объявлений; - - статистику по объявлениям и расходам; - - call stats; - - обновление цены; - - применение VAS и VAS packages; - - цены на услуги продвижения; - - все отчеты и profile API автозагрузки; - - `ad_ids`, `avito_ids`, tree/fields, upload by URL. -- Для deprecated операций автозагрузки сделать `legacy`-объект или `legacy`-методы с явным именованием. - -Тесты: - -- Mapping-тесты на все основные модели объявлений, статистики, spendings и autoload reports. -- Тесты query/body/path параметров для фильтров и batch-операций. -- Тесты deprecated wrappers, что они помечены и вызывают правильный endpoint. -- Тесты upload/profile/report flows автозагрузки. - -Критерии готовности: - -- Пользователь может пройти сценарий `avito.account(user_id).get_self()`, `avito.ad(item_id).get()`, `avito.ad(item_id).get_stats(...)`, `avito.autoload_report(report_id).get()`. -- Все ответы этого блока возвращают dataclass-модели, а не `dict`. - -## Этап 5. Messenger и messaging-adjacent API - -Покрыть разделы: - -- `Мессенджер` -- `Рассылка скидок и спецпредложений в мессенджере` - -Что сделать: - -- Реализовать `MessengerClient` и вложенный `SpecialOffersClient`. -- Покрыть: - - список чатов; - - чат по ID; - - сообщения V3; - - отправку текста; - - отправку изображения; - - удаление сообщения; - - отметку чата как прочитанного; - - voice files; - - upload images; - - blacklist; - - webhook subscribe/unsubscribe/list; - - available offers, draft create, confirm, stats, tariff info. -- Отдельно спроектировать модели для webhook subscription и media upload results. - -Тесты: - -- Контрактные тесты сериализации webhook payload и subscribe/unsubscribe flows. -- Тесты multipart/image upload. -- Mapping-тесты чатов, сообщений и голосовых файлов. -- Интеграционные мок-тесты цепочки `upload image -> send image message`. -- Тесты offer campaign flow: `available -> multiCreate -> multiConfirm`. - -Критерии готовности: - -- Пользователь может пройти end-to-end сценарий переписки и рассылки без обращения к raw HTTP. -- Все webhook-методы имеют стабильные typed request/result модели. - -## Этап 6. Promotion stack - -Покрыть разделы: - -- `Продвижение` -- `TrxPromo` -- `CPA-аукцион` -- `Настройка цены целевого действия` -- `Автостратегия` - -Что сделать: - -- Реализовать `PromotionClient` и при необходимости вложенные клиенты: - - `BbipClient`; - - `TrxPromoClient`; - - `CpaAuctionClient`; - - `TargetActionPriceClient`; - - `AutostrategyClient`. -- Покрыть: - - словари услуг, список услуг, заявки, статусы заявок; - - прогнозы, budget suggests, подключение услуг; - - apply/cancel/commissions для TrxPromo; - - get/save bids для аукциона; - - get bids, promotions by item ids, remove, set auto/manual; - - budget calculation, create/edit/info/stop/list/stat кампаний. -- Выделить общие модели бюджетов, ставок, кампаний и заявок. - -Тесты: - -- Mapping-тесты бюджетов, ставок, кампаний и статусов заявок. -- Тесты на идемпотентность и корректный HTTP method (`PUT` против `POST`) для подключения услуг. -- Тесты batch-операций по нескольким объявлениям. -- Тесты legacy/deprecated, если в ответах есть старые формы. - -Критерии готовности: - -- Все promotion API доступны из одного доменного блока без дублирования transport-логики. -- Публичный API отражает предметные действия, а не названия raw endpoints. - -## Этап 7. Orders, delivery, stock management - -Покрыть разделы: - -- `Управление заказами` -- `Доставка` -- `Управление остатками` - -Что сделать: - -- Реализовать `OrdersClient`, `DeliveryClient`, `SandboxDeliveryClient`, `StockClient`. -- В `orders` покрыть: - - получение заказов; - - transitions; - - confirmation code; - - cnc details; - - courier ranges; - - tracking number; - - return order; - - markings; - - labels tasks; - - labels PDF download. -- В `delivery` покрыть production и sandbox сценарии: - - announcement create/cancel/track; - - parcel create/cancel/change/info; - - tariff/terminals/areas/sorting centers; - - prohibit acceptance; - - custom schedule; - - tasks polling; - - change result callbacks. -- В `stock` покрыть info и update stocks. - -Тесты: - -- Тесты бинарной загрузки PDF-этикеток. -- Тесты polling long-running tasks по `task_id`. -- Тесты sandbox/prod route separation. -- Mapping-тесты заказов, маркировок, диапазонов доставки, посылок, тарифов и остатков. -- Тесты optimistic retry only for safe polling requests. - -Критерии готовности: - -- Production и sandbox API доставки не смешаны в одном наборе методов. -- Пользователь может получить PDF-этикетку и обработать lifecycle заказа через typed API. - -## Этап 8. Jobs API - -Покрыть раздел: - -- `Авито.Работа` - -Что сделать: - -- Реализовать `JobsClient` с подразделами: - - `VacanciesClient`; - - `ApplicationsClient`; - - `ResumesClient`; - - `WebhookClient`; - - `DictionariesClient`. -- Покрыть обе версии вакансий и резюме: - - publish/edit/archive/prolongate; - - v2 create/update/statuses/batch/get/list/auto_renewal; - - applications ids/by_ids/states/apply_actions/set_is_viewed; - - webhook CRUD/list; - - resumes search/get/get contacts; - - vacancy dictionaries. -- Развести `v1` и `v2` модели там, где контракт реально отличается. - -Тесты: - -- Mapping-тесты вакансий, резюме, откликов, словарей и статусов публикации. -- Тесты batch vacancy flows. -- Тесты webhook CRUD. -- Тесты, что `v1` и `v2` не смешивают несовместимые поля. - -Критерии готовности: - -- Полный lifecycle вакансии и отклика покрыт публичным API. -- Версионные различия инкапсулированы в моделях и клиентах, а не размазаны по условным `dict`. - -## Этап 9. CPA и CallTracking - -Покрыть разделы: - -- `CPA Авито` -- `CallTracking[КТ]` - -Что сделать: - -- Реализовать `CpaClient` и `CallTrackingClient`. -- Покрыть: - - chat by action id; - - chats by time `v1` deprecated и `v2`; - - call by id `v1` deprecated и `v2`; - - calls by time; - - complaints; - - phones info from chats; - - balance info `v2` deprecated и `v3`; - - calltracking get call, get calls, get record. -- Для аудиофайлов и записей звонков ввести typed binary result. - -Тесты: - -- Mapping-тесты звонков, чатов, балансов и complaint responses. -- Тесты legacy wrappers на deprecated endpoints. -- Тесты бинарной выдачи аудиозаписи звонка. -- Тесты временных фильтров и batch phone info requests. - -Критерии готовности: - -- Все CPA и call tracking методы доступны из отдельных логически чистых клиентов. -- Deprecated API поддержаны без загрязнения основного современного интерфейса. - -## Этап 10. Autoteka - -Покрыть раздел: - -- `Автотека` - -Что сделать: - -- Реализовать `AutotekaClient` с подразделами: - - `CatalogClient`; - - `PreviewClient`; - - `ReportClient`; - - `ScoringClient`; - - `SpecificationsClient`; - - `MonitoringClient`; - - `TeaserClient`; - - `ValuationClient`; - - `PackageClient`. -- Покрыть: - - resolve catalogs; - - active package; - - all preview creation variants; - - previews by id; - - reports, reports by vehicle id, report list, report by id; - - sync reports by regnumber/vin; - - scoring create/get; - - specification create/get; - - monitoring add/remove/delete/get events; - - teasers create/get; - - valuation; - - leads/signal events. -- Отдельно оформить auth-требования, если токен автотеки живет отдельно. - -Тесты: - -- Mapping-тесты всех крупных сущностей: preview, report, scoring, specification, teaser, package balance, monitoring event. -- Тесты нескольких альтернативных request-моделей для одного бизнес-действия. -- Тесты polling/report retrieval после create. -- Тесты auth boundary для отдельного токена автотеки. - -Критерии готовности: - -- Сложный большой API разбит на малые доменные объекты и mappers. -- Пользователь может последовательно пройти сценарий `preview -> report -> scoring/specification`. - -## Этап 11. Realty, ratings, tariffs - -Покрыть разделы: - -- `Краткосрочная аренда` -- `Аналитика по недвижимости` -- `Рейтинги и отзывы` -- `Тарифы` - -Что сделать: - -- Реализовать `RealtyClient`, `RatingsClient`, `TariffsClient`. -- В `realty` покрыть: - - bookings get/fill; - - prices update for periods; - - intervals fill; - - base params set; - - market price correspondence; - - analytics report create. -- В `ratings` покрыть rating info, reviews pagination, create/delete answer. -- В `tariffs` покрыть transport tariff info. - -Тесты: - -- Mapping-тесты броней, периодов, аналитических отчетов, отзывов, рейтинга, tariff info. -- Тест пагинации отзывов. -- Тесты mutation-сценариев answer create/delete и booking update. -- Тесты path params для itemId/price/report create. - -Критерии готовности: - -- Все remaining sections из `docs/` реализованы. -- Нет разделов swagger без соответствующего доменного объекта. - -## Этап 12. Сквозная стабилизация, документация и релизный gate - -Что сделать: - -- Добавить `pytest`, `respx` или аналог для HTTP mocking, `mypy`, `ruff`. -- Зафиксировать `mypy` в strict-режиме или профиле, эквивалентном требованиям `STYLEGUIDE.md`. -- Довести покрытие unit + contract tests до уровня, достаточного для безопасного рефакторинга. -- Добавить обязательные примеры использования публичных методов: - - короткие примеры в docstring или рядом с методом там, где это уместно; - - сценарные примеры в `README.md` по всем основным доменам; - - не менее одного end-to-end примера на каждый крупный пакет SDK. -- Добавить changelog-политику и release checklist. -- Подготовить минимальные low-level debugging hooks, не нарушающие публичный API. -- Запустить финальную ревизию имен методов и моделей, чтобы в API не осталось HTTP-терминов без необходимости. - -Тесты: - -- `poetry run pytest` -- `poetry run mypy avito` -- `poetry run ruff check .` -- `poetry build` -- Проверка, что конфиг `mypy` соответствует минимальному strict-профилю из `STYLEGUIDE.md`: `strict = true`, `warn_unused_ignores = true`, `warn_redundant_casts = true`, `warn_return_any = true`, `disallow_any_generics = true`, `no_implicit_optional = true`. - -Критерии готовности: - -- Все этапы выше закрыты. -- Все swagger-разделы имеют реализованные доменные объекты, модели, мапперы и тесты. -- `mypy` работает в strict-режиме или эквивалентном профиле, соответствующем `STYLEGUIDE.md`. -- Сборка пакета проходит стабильно. -- Все классы задокументированы обязательными docstring. -- README показывает объектный API, соответствующий `STYLEGUIDE.md`, и содержит примеры использования ключевых методов. - -## Матрица покрытия по пакетам - -- `auth`: Авторизация, Autoteka token. -- `accounts`: Информация о пользователе, Иерархия аккаунтов. -- `ads`: Объявления, Автозагрузка. -- `messenger`: Мессенджер, Special offers. -- `promotion`: Продвижение, TrxPromo, CPA-аукцион, Целевое действие, Автостратегия. -- `orders`: Управление заказами, Доставка, Управление остатками. -- `jobs`: Авито.Работа. -- `cpa`: CPA Авито, CallTracking. -- `autoteka`: Автотека. -- `realty`: Краткосрочная аренда, Аналитика по недвижимости. -- `ratings`: Рейтинги и отзывы. -- `tariffs`: Тарифы. - -## Определение готовности проекта - -Проект считается завершенным только если одновременно выполнено все ниже: - -- каждый endpoint из `docs/*.json` сопоставлен публичному или legacy-методу SDK; -- это соответствие зафиксировано в `docs/inventory.md` и проверяемо по тестам; -- ни один публичный метод не возвращает сырой JSON; -- transport/auth/errors/retries изолированы от доменных клиентов; -- для каждого этапа есть regression-тесты; -- deprecated API явно отделены и задокументированы; -- строгая типизация подтверждена `mypy` strict или эквивалентным профилем из `STYLEGUIDE.md`; -- `poetry build` и полный тестовый набор проходят без ручных правок. +--- + +## Сводная таблица файлов + +| Файл | Изменение | +|------|-----------| +| `avito/promotion/domain.py` | Шаг 1: убрать `Any` | +| `avito/ads/domain.py` | Шаг 1: убрать `Any` | +| `avito/ads/models.py` | Шаг 2: добавить `SerializableModel` 12 классам | +| `avito/promotion/models.py` | Шаг 3: добавить `SerializableModel` 7 классам | +| `avito/*/enums.py` (11 файлов) | Шаг 4: удалить | +| `README.md` | Шаг 5: исправить env var | +| `STYLEGUIDE.md` | Шаг 6: исправить метод | +| `CHANGELOG.md` | Шаг 7: добавить запись | +| `tests/test_stage8_serialization_contract.py` | Шаги 2, 3: snapshot-тесты | +| `tests/test_readme_examples.py` | Шаги 5, 6: env var + метод тесты | diff --git a/avito/accounts/enums.py b/avito/accounts/enums.py deleted file mode 100644 index 6990484..0000000 --- a/avito/accounts/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета accounts.""" diff --git a/avito/ads/domain.py b/avito/ads/domain.py index 0cec4ee..603b5d5 100644 --- a/avito/ads/domain.py +++ b/avito/ads/domain.py @@ -2,9 +2,8 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from dataclasses import dataclass -from typing import Any from avito.ads.client import ( AdsClient, @@ -46,7 +45,7 @@ from avito.promotion.models import PromotionActionResult -def _validate_non_empty_items(name: str, items: Sequence[Any]) -> None: +def _validate_non_empty_items(name: str, items: Sequence[object]) -> None: if not items: raise ValidationError(f"`{name}` must contain at least one item.") @@ -65,8 +64,8 @@ def _validate_string_items(name: str, values: Sequence[str]) -> None: def _preview_result( *, action: str, - target: dict[str, Any], - request_payload: dict[str, Any], + target: Mapping[str, object], + request_payload: Mapping[str, object], ) -> PromotionActionResult: return PromotionActionResult( action=action, diff --git a/avito/ads/enums.py b/avito/ads/enums.py deleted file mode 100644 index 07f3b0d..0000000 --- a/avito/ads/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета ads.""" diff --git a/avito/ads/models.py b/avito/ads/models.py index e9338b0..2831565 100644 --- a/avito/ads/models.py +++ b/avito/ads/models.py @@ -41,7 +41,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class UpdatePriceResult: +class UpdatePriceResult(SerializableModel): """Результат обновления цены объявления.""" item_id: int | None @@ -130,7 +130,7 @@ class ItemStatsResult(SerializableModel): @dataclass(slots=True, frozen=True) -class ItemAnalyticsResult: +class ItemAnalyticsResult(SerializableModel): """Аналитика по профилю или объявлениям.""" items: list[ItemStatsRecord] @@ -185,14 +185,14 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class VasPricesResult: +class VasPricesResult(SerializableModel): """Список цен и доступных услуг продвижения.""" items: list[VasPrice] @dataclass(slots=True, frozen=True) -class VasApplyResult: +class VasApplyResult(SerializableModel): """Результат применения услуг продвижения.""" success: bool @@ -267,7 +267,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class UploadResult: +class UploadResult(SerializableModel): """Результат запуска загрузки файла.""" success: bool @@ -285,7 +285,7 @@ class AutoloadField: @dataclass(slots=True, frozen=True) -class AutoloadFieldsResult: +class AutoloadFieldsResult(SerializableModel): """Список полей категории автозагрузки.""" items: list[AutoloadField] @@ -301,14 +301,14 @@ class AutoloadTreeNode: @dataclass(slots=True, frozen=True) -class AutoloadTreeResult: +class AutoloadTreeResult(SerializableModel): """Дерево категорий автозагрузки.""" items: list[AutoloadTreeNode] @dataclass(slots=True, frozen=True) -class IdMappingResult: +class IdMappingResult(SerializableModel): """Сопоставление идентификаторов объявлений.""" mappings: list[tuple[int | None, int | None]] @@ -326,7 +326,7 @@ class AutoloadReportSummary: @dataclass(slots=True, frozen=True) -class AutoloadReportsResult: +class AutoloadReportsResult(SerializableModel): """Список отчетов автозагрузки.""" items: list[AutoloadReportSummary] @@ -344,7 +344,7 @@ class AutoloadReportItem: @dataclass(slots=True, frozen=True) -class AutoloadReportItemsResult: +class AutoloadReportItemsResult(SerializableModel): """Список объявлений из отчета автозагрузки.""" items: list[AutoloadReportItem] @@ -361,7 +361,7 @@ class AutoloadFee: @dataclass(slots=True, frozen=True) -class AutoloadFeesResult: +class AutoloadFeesResult(SerializableModel): """Списания по объявлениям отчета.""" items: list[AutoloadFee] @@ -389,7 +389,7 @@ class LegacyAutoloadReport: @dataclass(slots=True, frozen=True) -class ActionResult: +class ActionResult(SerializableModel): """Универсальный результат мутационной операции ads.""" success: bool diff --git a/avito/autoteka/enums.py b/avito/autoteka/enums.py deleted file mode 100644 index c465198..0000000 --- a/avito/autoteka/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета autoteka.""" diff --git a/avito/cpa/enums.py b/avito/cpa/enums.py deleted file mode 100644 index acc4731..0000000 --- a/avito/cpa/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета cpa.""" diff --git a/avito/jobs/enums.py b/avito/jobs/enums.py deleted file mode 100644 index 2b491e3..0000000 --- a/avito/jobs/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета jobs.""" diff --git a/avito/messenger/enums.py b/avito/messenger/enums.py deleted file mode 100644 index 4c4d4a2..0000000 --- a/avito/messenger/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета messenger.""" diff --git a/avito/orders/enums.py b/avito/orders/enums.py deleted file mode 100644 index 424e4e0..0000000 --- a/avito/orders/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета orders.""" diff --git a/avito/promotion/domain.py b/avito/promotion/domain.py index 83bef70..9bc5f0b 100644 --- a/avito/promotion/domain.py +++ b/avito/promotion/domain.py @@ -2,9 +2,8 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from dataclasses import dataclass -from typing import Any from avito.core import Transport, ValidationError from avito.promotion.client import ( @@ -59,7 +58,7 @@ ) -def _validate_non_empty_items(name: str, items: Sequence[Any]) -> None: +def _validate_non_empty_items(name: str, items: Sequence[object]) -> None: if not items: raise ValidationError(f"`{name}` must contain at least one item.") @@ -83,8 +82,8 @@ def _validate_string_items(name: str, values: Sequence[str]) -> None: def _preview_result( *, action: str, - target: dict[str, Any], - request_payload: dict[str, Any], + target: Mapping[str, object], + request_payload: Mapping[str, object], ) -> PromotionActionResult: return PromotionActionResult( action=action, diff --git a/avito/promotion/enums.py b/avito/promotion/enums.py deleted file mode 100644 index 3071860..0000000 --- a/avito/promotion/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета promotion.""" diff --git a/avito/promotion/models.py b/avito/promotion/models.py index ac1011e..8c8bd92 100644 --- a/avito/promotion/models.py +++ b/avito/promotion/models.py @@ -563,7 +563,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class AutostrategyBudgetPoint: +class AutostrategyBudgetPoint(SerializableModel): """Оценка бюджета автокампании.""" total: int | None @@ -576,7 +576,7 @@ class AutostrategyBudgetPoint: @dataclass(slots=True, frozen=True) -class AutostrategyPriceRange: +class AutostrategyPriceRange(SerializableModel): """Ценовой диапазон бюджета автокампании.""" price_from: int | None @@ -589,7 +589,7 @@ class AutostrategyPriceRange: @dataclass(slots=True, frozen=True) -class AutostrategyBudget: +class AutostrategyBudget(SerializableModel): """Расчет бюджета автокампании.""" calc_id: int | None @@ -622,14 +622,14 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class CampaignActionResult: +class CampaignActionResult(SerializableModel): """Результат операции с автокампанией.""" campaign: CampaignInfo | None @dataclass(slots=True, frozen=True) -class CampaignInfo: +class CampaignInfo(SerializableModel): """Информация об автокампании.""" campaign_id: int | None @@ -682,7 +682,7 @@ class CampaignDetailsResult(SerializableModel): @dataclass(slots=True, frozen=True) -class CampaignsResult: +class CampaignsResult(SerializableModel): """Список автокампаний.""" items: list[CampaignInfo] @@ -690,7 +690,7 @@ class CampaignsResult: @dataclass(slots=True, frozen=True) -class AutostrategyStat: +class AutostrategyStat(SerializableModel): """Статистика автокампании.""" items: list[AutostrategyStatItem] diff --git a/avito/ratings/enums.py b/avito/ratings/enums.py deleted file mode 100644 index c1725ee..0000000 --- a/avito/ratings/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета ratings.""" diff --git a/avito/realty/enums.py b/avito/realty/enums.py deleted file mode 100644 index 50ae684..0000000 --- a/avito/realty/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета realty.""" diff --git a/avito/tariffs/enums.py b/avito/tariffs/enums.py deleted file mode 100644 index 9bd2e13..0000000 --- a/avito/tariffs/enums.py +++ /dev/null @@ -1 +0,0 @@ -"""Строковые enum и константы пакета tariffs.""" diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py index 8ad6294..b89d17d 100644 --- a/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -54,6 +54,11 @@ def test_readme_uses_current_autostrategy_request_models() -> None: def test_readme_references_current_public_method_names() -> None: + from avito.ads.domain import AdPromotion + + assert hasattr(AdPromotion, "apply_vas_direct") + assert not hasattr(AdPromotion, "apply_vas_v2") + assert hasattr(AvitoClient, "autoload_archive") assert hasattr(AvitoClient, "cpa_archive") assert hasattr(Review, "list") @@ -73,6 +78,20 @@ def test_readme_references_current_public_method_names() -> None: assert "query" in review_signature +def test_auth_settings_env_var_names_match_readme() -> None: + from avito.auth.settings import AuthSettings + + supported = AuthSettings.supported_env_vars() + alternate_aliases = supported.get("alternate_token_url", ()) + + assert "AVITO_AUTH__ALTERNATE_TOKEN_URL" in alternate_aliases + assert "AVITO_ALTERNATE_TOKEN_URL" in alternate_aliases + assert "ALTERNATE_TOKEN_URL" in alternate_aliases + + all_aliases = {alias for aliases in supported.values() for alias in aliases} + assert not any("LEGACY" in a for a in all_aliases) + + def test_readme_uses_current_autoteka_request_models() -> None: assert CatalogResolveRequest(brand_id=1).to_payload() == {"brandId": 1} assert MonitoringBucketRequest(vehicles=["VIN-1"]).to_payload() == {"vehicles": ["VIN-1"]} diff --git a/tests/test_stage8_serialization_contract.py b/tests/test_stage8_serialization_contract.py index 589c9fd..d82ab59 100644 --- a/tests/test_stage8_serialization_contract.py +++ b/tests/test_stage8_serialization_contract.py @@ -111,6 +111,54 @@ def test_recursive_serialization_is_json_compatible_and_hides_transport_fields() json.dumps(request.to_dict()) +def test_ads_result_models_serialize_correctly() -> None: + from avito.ads.models import IdMappingResult, UpdatePriceResult + + r = UpdatePriceResult(item_id=42, price=999.0, status="active") + assert r.to_dict() == {"item_id": 42, "price": 999.0, "status": "active"} + json.dumps(r.to_dict()) + + m = IdMappingResult(mappings=[]) + assert m.to_dict() == {"mappings": []} + + +def test_autostrategy_models_serialize_correctly() -> None: + from avito.promotion.models import ( + AutostrategyBudget, + AutostrategyStat, + AutostrategyStatItem, + AutostrategyStatTotals, + CampaignActionResult, + CampaignsResult, + ) + + budget = AutostrategyBudget( + calc_id=1, recommended=None, minimal=None, maximal=None, price_ranges=[] + ) + assert budget.to_dict() == { + "calc_id": 1, + "recommended": None, + "minimal": None, + "maximal": None, + "price_ranges": [], + } + json.dumps(budget.to_dict()) + + result = CampaignActionResult(campaign=None) + assert result.to_dict() == {"campaign": None} + + campaigns = CampaignsResult(items=[], total_count=0) + assert campaigns.to_dict() == {"items": [], "total_count": 0} + + stat = AutostrategyStat( + items=[AutostrategyStatItem(date="2026-01-01", calls=5, views=10)], + totals=AutostrategyStatTotals(calls=5, views=10), + ) + dumped = stat.to_dict() + assert dumped["totals"] == {"calls": 5, "views": 10} + json.dumps(dumped) + + def test_binary_result_models_serialize_without_transport_objects() -> None: response = BinaryResponse( content=b"\x00\x01payload", From 9fa2d5080ebda51117566ef9c2dc2a96440abed9 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Sun, 19 Apr 2026 17:47:29 +0300 Subject: [PATCH 07/17] =?UTF-8?q?=D0=9F=D0=BE=D1=85=D0=BE=D0=B6=D0=B5=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20=D0=B2=D1=81=D0=B5=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 5 +- STYLEGUIDE.md | 281 +++++++++++++++++++++++++++++----- tests/test_inventory.py | 141 ----------------- tests/test_readme_examples.py | 48 +----- 4 files changed, 245 insertions(+), 230 deletions(-) delete mode 100644 tests/test_inventory.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9306643..2762027 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,10 @@ "Bash(find *)", "Bash(make typecheck *)", "Bash(make test *)", - "Bash(make check *)" + "Bash(make check *)", + "Bash(.venv/bin/ruff check *)", + "Bash(.venv/bin/ruff format *)", + "Bash(.venv/bin/pytest tests/ -q)" ] } } diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index 364ca54..0c987a8 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -2,7 +2,7 @@ ## Цель -Этот документ задает единый стиль разработки Python SDK для Avito API. +Этот документ задаёт единый стиль разработки Python SDK для Avito API. Цель библиотеки: - дать понятный и прозрачный публичный API; @@ -15,7 +15,13 @@ ## Базовые принципы +Принципы оформлены в порядке убывания приоритета при конфликте. + - Код должен быть читаемым раньше, чем компактным. +- Явное лучше неявного: каждый публичный контракт читается без знания деталей реализации. +- Простое лучше сложного: добавлять абстракцию только тогда, когда без неё нельзя обойтись. +- Для каждой задачи должен быть один очевидный способ — не два и не три. +- Ошибки не должны проходить молча: некорректное состояние обнаруживается как можно раньше. - Публичный API библиотеки должен быть простым, внутренние детали должны быть инкапсулированы. - Каждый слой отвечает только за свою задачу: transport, auth, API clients, domain models, mapping, errors. - Внешний код не должен работать с сырыми `dict[str, Any]`, если можно вернуть типизированный объект. @@ -78,7 +84,7 @@ avito/ Правила: - `core/` содержит только общую инфраструктуру, без логики конкретного API-раздела. -- Каждый раздел API живет в отдельном пакете: `ads`, `messenger`, `orders`, `autoload` и т.д. +- Каждый раздел API живёт в отдельном пакете: `ads`, `messenger`, `orders`, `autoload` и т.д. - В каждом разделе допускаются только модули, относящиеся к этому разделу. - `avito/client.py` и `avito/__init__.py` содержат только высокоуровневую точку входа и публичные экспорты. @@ -100,6 +106,14 @@ stats = client.ad_stats(user_id=123).get_item_stats(item_ids=[42]) - Публичные методы возвращают доменные модели, коллекции доменных моделей или типизированные result-объекты. - Сырые ответы API допустимы только во внутренних слоях или в явно обозначенных low-level методах. +### Один путь к каждой операции + +Для каждой операции в публичном API должен быть ровно один очевидный способ её выполнить. Если два разных объекта делают одно и то же — это ошибка проектирования. + +- Запрещено дублировать поведение через разные фасады: `ad().get_stats()` и `ad_stats().get_item_stats()` для одного набора данных недопустимы одновременно. +- Если один метод покрывает частный случай, а другой — общий, частный должен быть обёрткой над общим, а не независимой реализацией. +- Псевдонимы типов (`Listing = AdItem`) без явной deprecation-метки запрещены: у каждого публичного типа должно быть одно каноническое имя. + ### Что считается публичным контрактом SDK Нормативно в публичный контракт входят: @@ -121,6 +135,33 @@ stats = client.ad_stats(user_id=123).get_item_stats(item_ids=[42]) Внутренние изменения допустимы, пока публичные сигнатуры, возвращаемые модели, сериализация и типы ошибок остаются стабильными. +## Инициализация клиента + +Пользователь должен иметь простой путь к первому рабочему вызову. + +Нормативно поддерживаемые способы создания клиента (от простого к явному): + +```python +# 1. Из переменных окружения +with AvitoClient.from_env() as avito: + ... + +# 2. Явная передача credentials — обязательный shortcut +with AvitoClient(client_id="...", client_secret="...") as avito: + ... + +# 3. Полная конфигурация через settings +settings = AvitoSettings(auth=AuthSettings(client_id="...", client_secret="...")) +with AvitoClient(settings) as avito: + ... +``` + +Правила: + +- `AvitoClient` обязан принимать `client_id` и `client_secret` напрямую без промежуточного объекта `AuthSettings`. +- `AvitoClient.from_env()` является официальным factory method для инициализации из окружения. +- Вложенность `AvitoSettings → AuthSettings` допустима как явный путь, но не должна быть единственным. + ## Классы и ответственность Обязательное разделение: @@ -171,6 +212,61 @@ class Message: created_at: datetime ``` +## Именование полей доменных объектов + +Имена полей должны точно отражать то, что в них хранится, без обобщений. + +Правила: + +- Запрещено использовать абстрактное имя `resource_id` в доменных объектах. Вместо него — конкретное имя поля: `item_id`, `user_id`, `report_id`, `order_id` и т.д. +- Если доменный объект принимает несколько идентификаторов, каждый объявляется явным полем с именем предметной области. +- Имена полей в публичных моделях не должны отражать детали HTTP или названий JSON-полей upstream API. + +```python +# Правильно +@dataclass(slots=True, frozen=True) +class Ad(DomainObject): + item_id: int | None = None + user_id: int | None = None + +# Неправильно +@dataclass(slots=True, frozen=True) +class Ad(DomainObject): + resource_id: int | str | None = None # что именно хранится — неизвестно + user_id: int | str | None = None +``` + +## Параметры публичных методов + +Публичный метод не должен требовать от пользователя конструировать внутренние объекты SDK. + +Правила: + +- Аргументы публичных методов должны быть примитивными типами (`int`, `str`, `bool`, `float`) или хорошо известными доменными моделями результата (не request-объектами). +- Request-DTO, используемые внутри section client-ов, не должны появляться в публичных сигнатурах domain-методов. +- Если метод требует сложного входного объекта, он должен принимать его поля напрямую как keyword-only аргументы. + +```python +# Правильно: примитивы и keyword-only +def create_order(self, *, item_id: int, duration: int, price: int) -> PromotionActionResult: + ... + +# Неправильно: внутренний request-объект утекает наружу +def create_order(self, *, items: list[BbipOrderItem]) -> PromotionActionResult: + ... +``` + +## Fail-fast и валидация состояния + +Некорректное состояние объекта должно обнаруживаться как можно раньше. + +Правила: + +- Если доменный объект не может выполнить ни одну операцию без конкретного идентификатора, этот идентификатор должен проверяться при создании объекта, а не при первом вызове метода. +- Фабричный метод, создающий объект в заведомо неполном состоянии, должен возвращать объект с ограниченным интерфейсом (только те методы, которые доступны без ID), а не объект с методами, падающими в рантайме. +- Ошибка конфигурации (`ConfigurationError`) должна бросаться до первого HTTP-запроса. +- Даты, передаваемые как параметры, должны принимать `datetime` или валидированный строковый формат — голый `str` без проверки не допускается, если формат имеет значение. + ## Pydantic и валидация Для этого проекта `dataclass` — стандарт представления доменных объектов. `pydantic` не должен быть базовым строительным блоком всей модели SDK. @@ -183,7 +279,7 @@ class Message: Недопустимое использование: -- смешивать `pydantic.BaseModel` и `dataclass` без четкого слоя ответственности; +- смешивать `pydantic.BaseModel` и `dataclass` без чёткого слоя ответственности; - возвращать `BaseModel` как основной публичный формат SDK, если доменная dataclass уже существует. ## Типизация и mypy @@ -193,11 +289,13 @@ class Message: Правила: - Все функции, методы, атрибуты классов и возвращаемые значения должны быть аннотированы. -- `Any` запрещен, кроме узких boundary-layer мест с локальным объяснением. +- `Any` запрещён, кроме узких boundary-layer мест с локальным объяснением. - Использовать `mypy` в строгом режиме или максимально близком к нему. -- Использовать `Protocol`, `TypeAlias`, `TypedDict` для границ, где dataclass еще не применим. +- Использовать `Protocol`, `TypeAlias`, `TypedDict` для границ, где dataclass ещё не применим. - JSON от внешнего API сначала трактуется как boundary-тип, затем маппится в dataclass. - Не возвращать объединения слишком широких типов вроде `dict | list | str | None`. +- Аннотация возвращаемого типа должна точно соответствовать типу значения в рантайме. Если метод возвращает `PaginatedList`, аннотация должна содержать `PaginatedList`, а не `list`. +- Мёртвый код не допускается: неиспользуемые `TypeVar`, импорты и псевдонимы должны быть удалены. Минимальный целевой профиль `mypy`: @@ -228,6 +326,7 @@ no_implicit_optional = true Рекомендация: - Сначала сделать качественный sync SDK. +- SDK является синхронным — это должно быть явно задокументировано в README и публичном API. - Async-версию добавлять отдельным слоем, а не смешивать sync/async в одних и тех же классах. ## Авторизация @@ -269,13 +368,14 @@ no_implicit_optional = true Правила: -- Для ошибок SDK создается иерархия собственных исключений в `core/exceptions.py`. +- Для ошибок SDK создаётся иерархия собственных исключений в `core/exceptions.py`. - Ошибка должна содержать минимум: `operation`, HTTP status, код ошибки Avito при наличии, человекочитаемое сообщение и безопасные metadata. - Ошибки 4xx и 5xx должны различаться типами. - Ошибки парсинга и ошибки transport должны различаться. - Mapping transport/HTTP/API ошибок в публичные ошибки SDK должен быть централизован. - Секреты, токены и чувствительные headers должны автоматически санитизироваться в сообщении и metadata. - Неизвестная ошибка upstream не должна протекать наружу как сырой transport exception. +- Все сообщения об ошибках пишутся на одном языке — русском. Смешивание языков в сообщениях об ошибках запрещено. Пример иерархии: @@ -283,7 +383,8 @@ no_implicit_optional = true class AvitoError(Exception): ... class TransportError(AvitoError): ... class ValidationError(AvitoError): ... -class AuthorizationError(AvitoError): ... +class AuthorizationError(AvitoError): ... # 403: недостаточно прав +class AuthenticationError(AvitoError): ... # 401: неверные credentials / токен class RateLimitError(AvitoError): ... class ConflictError(AvitoError): ... class UnsupportedOperationError(AvitoError): ... @@ -291,10 +392,13 @@ class UpstreamApiError(AvitoError): ... class ResponseMappingError(AvitoError): ... ``` +`AuthenticationError` (401) и `AuthorizationError` (403) — семантически разные ошибки, они не должны находиться в отношении наследования. Пользователь, ловящий `AuthorizationError`, не должен неожиданно получать ошибки аутентификации. + Нормативный mapping: - `400` и `422` маппятся в `ValidationError`, если это соответствует контракту операции; -- `401` и `403` маппятся в `AuthorizationError`; +- `401` маппится в `AuthenticationError`; +- `403` маппится в `AuthorizationError`; - `409` маппится в `ConflictError`; - `429` маппится в `RateLimitError`; - неподдерживаемая операция приводит к `UnsupportedOperationError`; @@ -405,15 +509,16 @@ Read-операции promotion surface должны возвращать тол - Имена функций и методов: `snake_case`. - Имена публичных методов должны описывать бизнес-действие: `get_item`, `list_messages`, `create_discount_campaign`. - Для публичных моделей использовать canonical имена предметной области, а не внутренние transport aliases. -- Избегать абстрактных имен вроде `utils`, `helpers`, `common2`, `manager2`. +- Избегать абстрактных имён вроде `utils`, `helpers`, `common2`, `manager2`. +- Запрещены generic-имена для идентификаторов: `resource_id`, `entity_id`, `obj_id`. Использовать конкретные имена: `item_id`, `order_id`, `user_id`. ## Конфигурация Правила: - Конфигурация SDK выделяется в отдельный модуль: `config.py` или `settings.py`. -- `AvitoSettings` и `AuthSettings` являются единственным официальным способом конфигурации SDK. -- Пользователь SDK должен иметь возможность передать конфигурацию явно через объект настроек. +- `AvitoSettings` и `AuthSettings` являются официальным способом конфигурации SDK. +- Пользователь SDK должен иметь возможность передать `client_id` и `client_secret` напрямую в `AvitoClient` без создания промежуточных объектов. - Переменные окружения читаются в одном месте через `AvitoSettings.from_env()` и `AuthSettings.from_env()`. - `AvitoClient.from_env()` является официальным factory method для инициализации клиента из environment. - Resolution process environment и `.env` должен быть детерминированным и одинаковым для всех entry point. @@ -421,6 +526,7 @@ Read-операции promotion surface должны возвращать тол - Поддерживаемые env-переменные и alias-имена должны быть задокументированы и считаться частью стабильного config contract. - Отсутствие обязательных полей конфигурации должно валидироваться до первого HTTP-запроса через typed exceptions с понятными сообщениями. - Сообщения и metadata ошибок конфигурации не должны содержать секретные значения. +- Количество допустимых env-переменных-синонимов для одного поля должно быть минимальным. Обобщённые имена вроде `SECRET` или `TOKEN` не должны быть официальными алиасами. Пример: @@ -445,6 +551,7 @@ class AvitoSettings: - `AvitoSettings.from_env()`; - `AuthSettings.from_env()`; - `AvitoClient.from_env()`; +- `AvitoClient(client_id=..., client_secret=...)`; - явная валидация обязательных auth-полей; - безопасный `debug_info()` contract без утечки `client_secret`, access token, refresh token и `Authorization` header. @@ -455,6 +562,7 @@ class AvitoSettings: Правила: - list-методы, использующие lazy pagination, возвращают результат с list-like коллекцией `PaginatedList` в поле `items`; +- аннотация типа поля `items` должна быть `PaginatedList[T]`, а не `list[T]` — аннотация должна соответствовать рантайму; - первая страница может быть уже загружена в момент получения результата; - чтение первых `N` элементов не должно загружать все страницы сразу; - итерация по первым `N` элементам должна выполнять только необходимое число page-запросов; @@ -475,7 +583,9 @@ class AvitoSettings: - результат сериализации должен быть JSON-compatible; - вложенные публичные модели должны сериализоваться рекурсивно; - nullable и optional-поля сериализуются по правилам зафиксированного контракта; -- сериализация не должна раскрывать transport-объекты, служебные ссылки и внутренние mapper-поля. +- сериализация не должна раскрывать transport-объекты, служебные ссылки и внутренние mapper-поля; +- методы `to_dict()` и `model_dump()` должны быть явно объявлены в классе или унаследованы от явного mixin — динамическое добавление методов через `globals()` или `setattr` в рантайме запрещено; +- наличие методов сериализации должно быть видно в определении класса без необходимости отслеживать побочные вызовы при импорте модуля. ## Логирование @@ -496,23 +606,117 @@ class AvitoSettings: - Комментарии используются только там, где нельзя выразить намерение кодом. - Комментарии не должны дублировать очевидное. -## Тестируемость +## Тестирование + +### Что тестировать + +Тест существует, чтобы зафиксировать техническое решение или контракт. Тест оправдан, если без него можно незаметно сломать поведение, которое важно для пользователя или для работы системы. + +Тестируются: + +- **Публичный контракт SDK**: сигнатуры фабричных методов, возвращаемые типы, поведение на пустом и частичном upstream payload. +- **Маппинг ошибок**: каждый значимый HTTP статус должен приводить к строго определённому типу исключения SDK; секреты в metadata не должны утекать. +- **Auth flow**: получение токена, refresh после 401, использование отдельных credentials для специализированных endpoint-ов. +- **Retry-логика**: retry срабатывает на допустимых сценариях (timeout, 5xx, rate limit) и не срабатывает на недопустимых (non-idempotent методы без явного разрешения). +- **Пагинация**: ленивая загрузка читает только нужные страницы; ошибка на последующей странице пробрасывается в момент чтения; пустая коллекция не инициирует лишних запросов; полная материализация загружает все страницы ровно один раз. +- **Сериализация**: `to_dict()` / `model_dump()` возвращает JSON-compatible структуру; transport-поля не попадают в результат; вложенные модели сериализуются рекурсивно. +- **Dry-run контракт**: при `dry_run=True` transport не вызывается; payload, сформированный в `dry_run` и в реальном вызове, идентичен при одинаковых входных данных. +- **Конфигурация**: обязательные поля проверяются до первого HTTP-запроса; приоритет process environment над `.env` детерминирован; секреты не попадают в `debug_info()`. +- **Безопасность данных**: секретные значения (токены, `client_secret`, `Authorization` header) не попадают ни в сообщения об ошибках, ни в metadata, ни в serialization output. + +Не тестируются: + +- Что конструктор принимает аргументы и сохраняет их в поля. +- Что dataclass содержит поле с определённым типом. +- Что функция возвращает `None`, если входное значение `None`. +- Что импорт модуля не падает. +- Логика, полностью реализованная сторонней библиотекой без кастомизации. +- Соответствие кода документации: тест не должен проверять, что README, inventory, docstring или комментарий описывает текущее поведение. Документация не является контрактом — она описывает код, а не наоборот. Если документация устарела, её нужно обновить, а не писать тест, который за этим следит. +- Наличие конкретного имени метода или атрибута через `hasattr`. Это проверка синтаксиса, а не поведения. Если метод переименован — сломается вызывающий код, а не тест на `hasattr`. + +Критерий: если тест невозможно сломать, не нарушив публичный контракт или техническое решение, — тест не нужен. + +### Архитектура тестов + +Тесты делятся по тому, что они проверяют, а не по тому, какой модуль они покрывают. + +**Уровни тестов:** + +- **Contract tests** — проверяют, что публичный API возвращает ожидаемые типы и структуры при корректном upstream payload. Используют fake transport. Не зависят от сети. +- **Error mapping tests** — проверяют, что каждый HTTP статус и каждый upstream error shape приводит к правильному типу исключения SDK с ожидаемыми полями. +- **Integration-style tests** — проверяют сквозные технические решения: retry, auth refresh, pagination. Используют контролируемый fake transport с заданными сценариями. +- **Security tests** — проверяют, что секреты не утекают ни через какой публичный путь: ошибки, serialization, debug_info. + +### Изоляция + +Тесты не ходят в сеть. Весь HTTP заменяется управляемым fake transport, который: + +- получает на вход заданный статус и payload для каждого запроса; +- позволяет проверить, был ли вызов совершён, сколько раз, с каким методом и телом; +- одинаково используется во всех тестах, которые проверяют публичный API. + +Секция clients, domain objects и transport тестируются изолированно друг от друга. + +### Структура теста -Style guide ориентирован на код, который легко тестировать. +Каждый тест проверяет один аспект поведения. Структура — Arrange / Act / Assert без вложенных условий. + +```python +def test_transport_retries_on_server_error_and_raises_after_exhaustion(): + # Arrange + transport = FakeTransport(responses=[ + FakeResponse(status=500), + FakeResponse(status=500), + FakeResponse(status=500), + ]) + + # Act / Assert + with pytest.raises(ServerError): + transport.request_json("GET", "/some/path", context=ctx) + + assert transport.call_count == 3 +``` Правила: -- Внешние зависимости передаются через конструктор. -- Нельзя захардкодить сетевые вызовы так, чтобы их нельзя было подменить в тестах. -- Transport, auth provider и section clients должны тестироваться отдельно. -- Mapping должен покрываться unit-тестами на реальных примерах Avito payload. -- Для unit/regression тестирования публичного SDK должен использоваться единый mock/fake transport. -- Для ключевых публичных моделей и результатов операций обязательны contract/snapshot tests. -- Для публичных read/write методов обязательны happy-path тесты через mock transport. -- Для write-методов с `dry_run` обязательны отдельные тесты, подтверждающие отсутствие write-вызова и идентичность payload builder. -- Typed error mapping должен быть покрыт отдельными тестами по статус-кодам и unsupported-сценариям. -- Lazy pagination должна покрываться regression-тестами на частичную итерацию, полную материализацию, пустую коллекцию и ошибку на последующих страницах. -- Сериализация публичных моделей должна покрываться отдельными contract-тестами. +- Имя теста описывает поведение, а не проверяемый метод: `test_transport_retries_on_server_error_and_raises_after_exhaustion`, а не `test_transport_request`. +- Один тест — один сценарий. Несколько `assert` допустимы, если они проверяют одно и то же поведение с разных сторон. +- Параметризация используется для набора эквивалентных входных данных: разные HTTP статусы, разные error shapes, разные варианты upstream payload. +- Фикстуры создают только инфраструктуру (fake transport, settings), но не скрывают логику теста. + +### Покрытие обязательных сценариев + +**Маппинг ошибок** — обязательно покрыть: + +- 400, 401, 403, 404, 409, 422, 429, 5xx → соответствующий тип исключения SDK; +- секреты в `metadata` и `headers` ошибки заменяются на `***`; +- неизвестный статус маппится в `UpstreamApiError`, не в generic `Exception`. + +**Auth flow** — обязательно покрыть: + +- успешное получение токена по `client_credentials`; +- автоматический refresh после 401 ровно один раз; +- `AuthenticationError` после неудачного refresh (повторный 401); +- изоляция credentials для отдельных token endpoint-ов. + +**Пагинация** — обязательно покрыть: + +- частичная итерация загружает только нужные страницы; +- полная материализация через `materialize()` загружает всё ровно один раз; +- пустая первая страница не вызывает дополнительных запросов; +- ошибка на последующей странице пробрасывается при чтении, а не при создании объекта. + +**Dry-run** — обязательно покрыть для каждого write-метода с `dry_run`: + +- при `dry_run=True` transport не получает ни одного вызова; +- payload в `dry_run=True` и `dry_run=False` при одинаковых входных данных идентичен; +- валидация входных данных при `dry_run=True` работает так же, как при `dry_run=False`. + +**Сериализация** — обязательно покрыть: + +- `to_dict()` возвращает только публичные поля без transport-объектов; +- вложенные модели сериализуются рекурсивно; +- результат проходит `json.dumps()` без исключений. ## Импорты и зависимости @@ -534,20 +738,13 @@ Style guide ориентирован на код, который легко те - Скрытых сетевых побочных эффектов в свойствах и dataclass. - Утечки transport-layer shapes и mapper-деталей в публичные сигнатуры и модели. - Неявного или недокументированного config resolution через environment. - -## Практический вывод для текущего репозитория - -При дальнейшем рефакторинге проекта нужно двигаться в сторону следующей модели: - -- заменить текущие `BaseModel` доменные сущности на `dataclass`; -- вынести HTTP и retry в `core/transport.py`; -- вынести авторизацию в отдельный пакет `auth/`; -- разбить API по предметным пакетам вместо одной общей клиентской реализации; -- ввести строгую конфигурацию `mypy`; -- заменить сырые словари ответа на собственные типизированные модели; -- закрепить `AvitoSettings` и `AuthSettings` как единственный config contract; -- закрепить стабильные публичные модели, serialization contract и lazy pagination semantics; -- унифицировать promotion read/write surface, включая `dry_run`; -- заменить `assert` на иерархию typed exceptions SDK и централизованный error mapping. - -Этот документ является базовым стандартом для всех следующих изменений в проекте. +- Абстрактных имён полей (`resource_id`) там, где предметное имя известно и однозначно. +- Динамического добавления методов к классам через `setattr`, патчинг через `globals()` и иную рантайм-магию. +- Двух публичных методов, делающих одно и то же, без явной пометки одного из них как deprecated. +- Псевдонимов типов без explicit deprecation. +- Аннотации `list[T]` там, где в рантайме возвращается `PaginatedList[T]`. +- `AuthenticationError` как подкласса `AuthorizationError`: 401 и 403 — разные ошибки. +- Сообщений об ошибках на смешанных языках: весь user-facing текст ошибок должен быть на одном языке. +- Обобщённых env-алиасов (`SECRET`, `TOKEN`) в официальном config contract. +- Мёртвого кода: неиспользуемых символов, псевдонимов и импортов. +- Request-объектов внутреннего слоя в публичных сигнатурах доменных методов. diff --git a/tests/test_inventory.py b/tests/test_inventory.py deleted file mode 100644 index efe2064..0000000 --- a/tests/test_inventory.py +++ /dev/null @@ -1,141 +0,0 @@ -from __future__ import annotations - -import json -import unicodedata -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -DOCS_DIR = ROOT / "docs" -INVENTORY = DOCS_DIR / "inventory.md" -HTTP_METHODS = {"get", "post", "put", "patch", "delete", "options", "head", "trace"} -INVENTORY_HEADER_MAP = { - "раздел": "section", - "документ": "document", - "метод": "method", - "путь": "path", - "описание": "summary", - "deprecated": "deprecated", - "пакет_sdk": "package_sdk", - "доменный_объект": "domain_object", - "публичный_метод_sdk": "public_method_sdk", - "тип_запроса": "type_request", - "тип_ответа": "type_response", - "тип_теста": "type_test", - "примечания": "notes", -} -DOC_TO_PACKAGE = { - "Авторизация.json": "auth", - "Информацияопользователе.json": "accounts", - "ИерархияАккаунтов.json": "accounts", - "Объявления.json": "ads", - "Автозагрузка.json": "ads", - "Мессенджер.json": "messenger", - "Рассылкаскидокиспецпредложенийвмессенджере.json": "messenger", - "Продвижение.json": "promotion", - "TrxPromo.json": "promotion", - "CPA-аукцион.json": "promotion", - "Настройкаценыцелевогодействия.json": "promotion", - "Автостратегия.json": "promotion", - "Управлениезаказами.json": "orders", - "Доставка.json": "orders", - "Управлениеостатками.json": "orders", - "АвитоРабота.json": "jobs", - "CPAАвито.json": "cpa", - "CallTracking[КТ].json": "cpa", - "Автотека.json": "autoteka", - "Краткосрочнаяаренда.json": "realty", - "Аналитикапонедвижимости.json": "realty", - "Рейтингииотзывы.json": "ratings", - "Тарифы.json": "tariffs", -} - - -def _normalize(value: str) -> str: - return "".join( - ch for ch in unicodedata.normalize("NFKC", value) if unicodedata.category(ch) != "Cf" - ) - - -def _resolve_doc_path(document: str) -> Path: - direct_path = DOCS_DIR / document - if direct_path.exists(): - return direct_path - - normalized_target = _normalize(document) - for candidate in DOCS_DIR.iterdir(): - if _normalize(candidate.name) == normalized_target: - return candidate - - raise FileNotFoundError(f"Swagger document not found: {document}") - - -def _read_inventory_rows() -> list[dict[str, str]]: - in_table = False - rows: list[dict[str, str]] = [] - header: list[str] = [] - for line in INVENTORY.read_text(encoding="utf-8").splitlines(): - if line.strip() == "": - in_table = True - continue - if line.strip() == "": - break - if not in_table or not line.startswith("|"): - continue - cells = [cell.strip() for cell in line.strip().strip("|").split("|")] - if cells and all(cell and set(cell) == {"-"} for cell in cells): - continue - if cells[0] == "раздел": - header = [INVENTORY_HEADER_MAP[cell] for cell in cells] - continue - rows.append(dict(zip(header, cells, strict=True))) - return rows - - -def _swagger_rows() -> list[dict[str, str]]: - rows: list[dict[str, str]] = [] - for document in sorted(DOC_TO_PACKAGE): - data = json.loads(_resolve_doc_path(document).read_text(encoding="utf-8")) - for path, path_item in data.get("paths", {}).items(): - for method, operation in path_item.items(): - if method.lower() not in HTTP_METHODS: - continue - rows.append( - { - "document": document, - "method": method.upper(), - "path": _normalize(path), - "deprecated": "да" if operation.get("deprecated", False) else "нет", - } - ) - return rows - - -def test_inventory_covers_all_swagger_operations() -> None: - inventory_rows = _read_inventory_rows() - inventory_index = { - (row["document"], row["method"], _normalize(row["path"]), row["deprecated"]): row - for row in inventory_rows - } - - swagger_rows = _swagger_rows() - - assert len(inventory_rows) == len(swagger_rows) - - for swagger_row in swagger_rows: - key = ( - swagger_row["document"], - swagger_row["method"], - swagger_row["path"], - swagger_row["deprecated"], - ) - assert key in inventory_index, key - - -def test_inventory_rows_are_complete_and_package_mapping_is_stable() -> None: - for row in _read_inventory_rows(): - assert row["package_sdk"] == DOC_TO_PACKAGE[row["document"]] - assert row["domain_object"] - assert row["public_method_sdk"] - assert row["type_request"] - assert row["type_response"] - assert row["type_test"] diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py index b89d17d..979ccc0 100644 --- a/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -1,9 +1,5 @@ from __future__ import annotations -import inspect - -from avito import AvitoClient -from avito.autoteka import AutotekaMonitoring, AutotekaReport, AutotekaVehicle from avito.autoteka.models import CatalogResolveRequest, MonitoringBucketRequest from avito.promotion.models import ( CampaignListFilter, @@ -12,10 +8,9 @@ CreateAutostrategyBudgetRequest, ListAutostrategyCampaignsRequest, ) -from avito.ratings import Review -def test_readme_uses_current_autostrategy_request_models() -> None: +def test_autostrategy_request_models_produce_correct_payload() -> None: budget_request = CreateAutostrategyBudgetRequest( campaign_type="AS", start_time="2026-04-20T00:00:00Z", @@ -53,45 +48,6 @@ def test_readme_uses_current_autostrategy_request_models() -> None: } -def test_readme_references_current_public_method_names() -> None: - from avito.ads.domain import AdPromotion - - assert hasattr(AdPromotion, "apply_vas_direct") - assert not hasattr(AdPromotion, "apply_vas_v2") - - assert hasattr(AvitoClient, "autoload_archive") - assert hasattr(AvitoClient, "cpa_archive") - assert hasattr(Review, "list") - assert hasattr(AutotekaVehicle, "resolve_catalog") - assert hasattr(AutotekaReport, "list_reports") - assert hasattr(AutotekaMonitoring, "delete_bucket") - assert hasattr(AutotekaMonitoring, "remove_bucket") - assert not hasattr(AvitoClient, "autoload_legacy") - assert not hasattr(AvitoClient, "cpa_legacy") - assert not hasattr(Review, "list_reviews_v1") - assert not hasattr(AutotekaVehicle, "get_catalogs_resolve") - assert not hasattr(AutotekaReport, "list_report_list") - assert not hasattr(AutotekaMonitoring, "list_monitoring_bucket_delete") - assert not hasattr(AutotekaMonitoring, "delete_monitoring_bucket_remove") - - review_signature = str(inspect.signature(Review.list)) - assert "query" in review_signature - - -def test_auth_settings_env_var_names_match_readme() -> None: - from avito.auth.settings import AuthSettings - - supported = AuthSettings.supported_env_vars() - alternate_aliases = supported.get("alternate_token_url", ()) - - assert "AVITO_AUTH__ALTERNATE_TOKEN_URL" in alternate_aliases - assert "AVITO_ALTERNATE_TOKEN_URL" in alternate_aliases - assert "ALTERNATE_TOKEN_URL" in alternate_aliases - - all_aliases = {alias for aliases in supported.values() for alias in aliases} - assert not any("LEGACY" in a for a in all_aliases) - - -def test_readme_uses_current_autoteka_request_models() -> None: +def test_autoteka_request_models_produce_correct_payload() -> None: assert CatalogResolveRequest(brand_id=1).to_payload() == {"brandId": 1} assert MonitoringBucketRequest(vehicles=["VIN-1"]).to_payload() == {"vehicles": ["VIN-1"]} From 5aa599067b1a5648adc399e418dba79dafef6d87 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Sun, 19 Apr 2026 22:25:40 +0300 Subject: [PATCH 08/17] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D0=B3=D0=B0=D0=B9=D0=B4=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- TODO.md | 246 ------------------------------------ avito/accounts/domain.py | 2 - avito/accounts/models.py | 23 ++-- avito/ads/__init__.py | 10 +- avito/ads/domain.py | 43 +++---- avito/ads/models.py | 32 ++--- avito/auth/provider.py | 5 +- avito/auth/settings.py | 4 - avito/autoteka/domain.py | 36 +++--- avito/autoteka/models.py | 39 +++--- avito/client/client.py | 106 +++++++++------- avito/core/exceptions.py | 8 +- avito/core/mapping.py | 3 - avito/core/serialization.py | 19 +-- avito/cpa/domain.py | 36 +++--- avito/cpa/models.py | 3 +- avito/jobs/domain.py | 44 +++---- avito/jobs/models.py | 45 +++---- avito/messenger/domain.py | 25 ++-- avito/messenger/models.py | 38 +++--- avito/orders/domain.py | 16 +-- avito/orders/models.py | 33 +++-- avito/promotion/__init__.py | 8 +- avito/promotion/domain.py | 132 +++++++++++++++---- avito/promotion/models.py | 6 +- avito/ratings/domain.py | 8 +- avito/ratings/models.py | 13 +- avito/realty/domain.py | 24 ++-- avito/realty/models.py | 3 +- avito/tariffs/domain.py | 2 +- avito/tariffs/models.py | 9 +- 32 files changed, 390 insertions(+), 634 deletions(-) delete mode 100644 TODO.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2762027..f6326b8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,8 @@ "Bash(make check *)", "Bash(.venv/bin/ruff check *)", "Bash(.venv/bin/ruff format *)", - "Bash(.venv/bin/pytest tests/ -q)" + "Bash(.venv/bin/pytest tests/ -q)", + "Bash(python *)" ] } } diff --git a/TODO.md b/TODO.md deleted file mode 100644 index a4b83f0..0000000 --- a/TODO.md +++ /dev/null @@ -1,246 +0,0 @@ -# TODO: Исправления STYLEGUIDE и API-контракт - -## Context - -Полный анализ проекта выявил 7 категорий несоответствий между кодом, STYLEGUIDE.md и README.md. -Все изменения направлены на устранение конкретных нарушений; backward-compatibility не соблюдается. -Порядок шагов: сначала код, затем тесты, в конце документация. - ---- - -## Шаг 1 — Исправить `Any` в domain-файлах - -**Нарушение:** STYLEGUIDE §Типизация: "`Any` запрещен, кроме узких boundary-layer мест с локальным объяснением". - -### 1.1 `avito/promotion/domain.py` - -| Строка | Было | Станет | -|--------|------|--------| -| 5 | `from typing import Any` | удалить | -| 62 | `items: Sequence[Any]` | `items: Sequence[object]` | -| 83 | `target: dict[str, Any]` | `target: dict[str, object]` | -| 84 | `request_payload: dict[str, Any]` | `request_payload: dict[str, object]` | - -### 1.2 `avito/ads/domain.py` - -| Строка | Было | Станет | -|--------|------|--------| -| 6 | `from typing import Any` | удалить | -| 49 | `items: Sequence[Any]` | `items: Sequence[object]` | -| 65 | `target: dict[str, Any]` | `target: dict[str, object]` | -| 66 | `request_payload: dict[str, Any]` | `request_payload: dict[str, object]` | - -**Тест:** `make typecheck` не должен выдавать `[assignment]` / `[arg-type]` по этим файлам. - ---- - -## Шаг 2 — Добавить `SerializableModel` публичным моделям в `avito/ads/models.py` - -**Нарушение:** STYLEGUIDE §Dataclass: "Каждая публичная модель должна предоставлять единообразную сериализацию через `to_dict()` и `model_dump()`." Классы ниже не наследуют `SerializableModel` — получают методы только через runtime-патч `enable_module_serialization`. При strict mypy вызов `.to_dict()` на них даст `[attr-defined]`. - -Добавить `(SerializableModel)` как базовый класс (без изменения полей): - -- `UpdatePriceResult` (строка 44) -- `ItemAnalyticsResult` (строка 133) -- `VasPricesResult` (строка 188) -- `VasApplyResult` (строка 195) -- `UploadResult` (строка 270) -- `AutoloadFieldsResult` (строка 288) -- `AutoloadTreeResult` (строка 304) -- `IdMappingResult` (строка 311) -- `AutoloadReportsResult` (строка 329) -- `AutoloadReportItemsResult` (строка 347) -- `AutoloadFeesResult` (строка 364) -- `ActionResult` (строка 392) - -**Тест:** добавить в `tests/test_stage8_serialization_contract.py`: - -```python -def test_ads_result_models_serialize_correctly() -> None: - from avito.ads.models import UpdatePriceResult, IdMappingResult - - r = UpdatePriceResult(item_id=42, price=999.0, status="active") - assert r.to_dict() == {"item_id": 42, "price": 999.0, "status": "active"} - json.dumps(r.to_dict()) - - m = IdMappingResult(items=[]) - assert m.to_dict() == {"items": []} -``` - ---- - -## Шаг 3 — Добавить `SerializableModel` публичным моделям в `avito/promotion/models.py` - -**Нарушение:** то же, что в шаге 2. - -Следующие классы возвращаются публичными методами `AutostrategyCampaign`, но не наследуют `SerializableModel`: - -- `AutostrategyBudgetPoint` (строка 566) -- `AutostrategyPriceRange` (строка 579) -- `AutostrategyBudget` (строка 592) — возвращается `autostrategy_campaign().create_budget()` -- `CampaignActionResult` (строка 625) — возвращается `create()`, `update()`, `delete()` -- `CampaignInfo` (строка 632) — вложена в `CampaignActionResult` и `CampaignsResult` -- `CampaignsResult` (строка 685) — возвращается `list()` -- `AutostrategyStat` (строка 693) — возвращается `get_stat()` - -Добавить `(SerializableModel)` как базовый класс. - -**Тест:** добавить в `tests/test_stage8_serialization_contract.py`: - -```python -def test_autostrategy_models_serialize_correctly() -> None: - from avito.promotion.models import ( - AutostrategyBudget, - CampaignActionResult, - CampaignsResult, - AutostrategyStat, - AutostrategyStatItem, - AutostrategyStatTotals, - ) - - budget = AutostrategyBudget( - calc_id=1, recommended=None, minimal=None, maximal=None, price_ranges=[] - ) - assert budget.to_dict() == { - "calc_id": 1, - "recommended": None, - "minimal": None, - "maximal": None, - "price_ranges": [], - } - json.dumps(budget.to_dict()) - - result = CampaignActionResult(campaign=None) - assert result.to_dict() == {"campaign": None} - - campaigns = CampaignsResult(items=[], total_count=0) - assert campaigns.to_dict() == {"items": [], "total_count": 0} - - stat = AutostrategyStat( - items=[AutostrategyStatItem(date="2026-01-01", calls=5, views=10)], - totals=AutostrategyStatTotals(calls=5, views=10), - ) - dumped = stat.to_dict() - assert dumped["totals"] == {"calls": 5, "views": 10} - json.dumps(dumped) -``` - ---- - -## Шаг 4 — Удалить пустые `enums.py` файлы - -**Нарушение:** STYLEGUIDE §Чего в проекте быть не должно: "устаревший код". Все перечисленные файлы содержат только docstring, не импортируются ни в одном модуле — мёртвый код. (`auth/enums.py` содержит константы и импортируется — его не трогать.) - -Удалить: -``` -avito/accounts/enums.py -avito/ads/enums.py -avito/autoteka/enums.py -avito/cpa/enums.py -avito/jobs/enums.py -avito/messenger/enums.py -avito/orders/enums.py -avito/promotion/enums.py -avito/ratings/enums.py -avito/realty/enums.py -avito/tariffs/enums.py -``` - -**Тест:** `make check` проходит без ошибок (lint, typecheck, test). - ---- - -## Шаг 5 — Исправить имя env-переменной в `README.md` - -**Нарушение:** README.md строка 81 документирует несуществующую переменную. - -``` -# Было: -- `AVITO_AUTH__LEGACY_TOKEN_URL`, alias: `AVITO_LEGACY_TOKEN_URL`, `LEGACY_TOKEN_URL` - -# Станет: -- `AVITO_AUTH__ALTERNATE_TOKEN_URL`, alias: `AVITO_ALTERNATE_TOKEN_URL`, `ALTERNATE_TOKEN_URL` -``` - -**Тест:** добавить в `tests/test_readme_examples.py`: - -```python -def test_auth_settings_env_var_names_match_readme() -> None: - from avito.auth.settings import AuthSettings - - supported = AuthSettings.supported_env_vars() - alternate_aliases = supported.get("alternate_token_url", ()) - - assert "AVITO_AUTH__ALTERNATE_TOKEN_URL" in alternate_aliases - assert "AVITO_ALTERNATE_TOKEN_URL" in alternate_aliases - assert "ALTERNATE_TOKEN_URL" in alternate_aliases - - all_aliases = {alias for aliases in supported.values() for alias in aliases} - assert not any("LEGACY" in a for a in all_aliases) -``` - ---- - -## Шаг 6 — Исправить STYLEGUIDE.md: `apply_vas_v2` → `apply_vas_direct` - -**Нарушение:** STYLEGUIDE.md строка 378 называет метод `apply_vas_v2()`, которого нет в коде. Метод переименован в `apply_vas_direct()`, но STYLEGUIDE не обновлён. - -``` -# STYLEGUIDE.md строка 378 -# Было: -- `ad_promotion().apply_vas_v2(...)` - -# Станет: -- `ad_promotion().apply_vas_direct(...)` -``` - -**Тест:** расширить `test_readme_references_current_public_method_names` в `tests/test_readme_examples.py`: - -```python - from avito.ads.domain import AdPromotion - - assert hasattr(AdPromotion, "apply_vas_direct") - assert not hasattr(AdPromotion, "apply_vas_v2") -``` - ---- - -## Шаг 7 — Обновить CHANGELOG.md - -Добавить в раздел `Unreleased`: - -```markdown -- Документация: исправлено имя env-переменной в README (`LEGACY_TOKEN_URL` → `ALTERNATE_TOKEN_URL`) -- Документация: исправлено имя метода в STYLEGUIDE (`apply_vas_v2` → `apply_vas_direct`) -- Типизация: `Sequence[Any]` и `dict[str, Any]` заменены на `Sequence[object]` - и `dict[str, object]` в `ads/domain.py` и `promotion/domain.py` -- Типизация: публичные result-модели autostrategy и ads теперь явно наследуют - `SerializableModel` вместо runtime-патча -- Удалены 11 пустых `enums.py` файлов (accounts, ads, autoteka, cpa, jobs, messenger, - orders, promotion, ratings, realty, tariffs) -``` - ---- - -## Шаг 8 — Финальная проверка - -```bash -make check # fmt + lint + typecheck + test + build -``` - ---- - -## Сводная таблица файлов - -| Файл | Изменение | -|------|-----------| -| `avito/promotion/domain.py` | Шаг 1: убрать `Any` | -| `avito/ads/domain.py` | Шаг 1: убрать `Any` | -| `avito/ads/models.py` | Шаг 2: добавить `SerializableModel` 12 классам | -| `avito/promotion/models.py` | Шаг 3: добавить `SerializableModel` 7 классам | -| `avito/*/enums.py` (11 файлов) | Шаг 4: удалить | -| `README.md` | Шаг 5: исправить env var | -| `STYLEGUIDE.md` | Шаг 6: исправить метод | -| `CHANGELOG.md` | Шаг 7: добавить запись | -| `tests/test_stage8_serialization_contract.py` | Шаги 2, 3: snapshot-тесты | -| `tests/test_readme_examples.py` | Шаги 5, 6: env var + метод тесты | diff --git a/avito/accounts/domain.py b/avito/accounts/domain.py index 59b857c..4e3f2a4 100644 --- a/avito/accounts/domain.py +++ b/avito/accounts/domain.py @@ -33,7 +33,6 @@ class DomainObject: class Account(DomainObject): """Доменный объект операций аккаунта.""" - resource_id: int | str | None = None user_id: int | str | None = None def get_self(self) -> AccountProfile: @@ -73,7 +72,6 @@ def get_operations_history( class AccountHierarchy(DomainObject): """Доменный объект иерархии аккаунтов.""" - resource_id: int | str | None = None user_id: int | str | None = None def get_status(self) -> AhUserStatus: diff --git a/avito/accounts/models.py b/avito/accounts/models.py index 0bcb32c..5bf63c3 100644 --- a/avito/accounts/models.py +++ b/avito/accounts/models.py @@ -4,7 +4,7 @@ from dataclasses import dataclass -from avito.core.serialization import SerializableModel, enable_module_serialization +from avito.core.serialization import SerializableModel @dataclass(slots=True, frozen=True) @@ -18,7 +18,7 @@ class AccountProfile(SerializableModel): @dataclass(slots=True, frozen=True) -class AccountBalance: +class AccountBalance(SerializableModel): """Баланс кошелька пользователя.""" user_id: int | None @@ -29,7 +29,7 @@ class AccountBalance: @dataclass(slots=True, frozen=True) -class OperationRecord: +class OperationRecord(SerializableModel): """Операция по аккаунту.""" id: str | None @@ -73,7 +73,7 @@ class OperationsHistoryResult(SerializableModel): @dataclass(slots=True, frozen=True) -class AhUserStatus: +class AhUserStatus(SerializableModel): """Статус пользователя в иерархии аккаунтов.""" user_id: int | None @@ -82,7 +82,7 @@ class AhUserStatus: @dataclass(slots=True, frozen=True) -class Employee: +class Employee(SerializableModel): """Сотрудник иерархии аккаунтов.""" employee_id: int | None @@ -93,7 +93,7 @@ class Employee: @dataclass(slots=True, frozen=True) -class EmployeesResult: +class EmployeesResult(SerializableModel): """Список сотрудников иерархии.""" items: list[Employee] @@ -101,7 +101,7 @@ class EmployeesResult: @dataclass(slots=True, frozen=True) -class CompanyPhone: +class CompanyPhone(SerializableModel): """Телефон компании.""" id: int | None @@ -110,7 +110,7 @@ class CompanyPhone: @dataclass(slots=True, frozen=True) -class CompanyPhonesResult: +class CompanyPhonesResult(SerializableModel): """Список телефонов компании.""" items: list[CompanyPhone] @@ -161,7 +161,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class EmployeeItem: +class EmployeeItem(SerializableModel): """Объявление сотрудника в иерархии.""" item_id: int | None @@ -171,7 +171,7 @@ class EmployeeItem: @dataclass(slots=True, frozen=True) -class EmployeeItemsResult: +class EmployeeItemsResult(SerializableModel): """Список объявлений сотрудника.""" items: list[EmployeeItem] @@ -179,7 +179,7 @@ class EmployeeItemsResult: @dataclass(slots=True, frozen=True) -class ActionResult: +class ActionResult(SerializableModel): """Результат мутационной операции accounts.""" success: bool @@ -204,4 +204,3 @@ class ActionResult: "OperationsHistoryResult", ) -enable_module_serialization(globals()) diff --git a/avito/ads/__init__.py b/avito/ads/__init__.py index 74722ac..15b1664 100644 --- a/avito/ads/__init__.py +++ b/avito/ads/__init__.py @@ -10,7 +10,6 @@ DomainObject, ) from avito.ads.models import ( - AccountSpendings, ActionResult, AdItem, AdsListResult, @@ -26,14 +25,11 @@ AutoloadReportSummary, AutoloadTreeNode, AutoloadTreeResult, - CallsStatsResult, CallStat, - CallStats, + CallsStatsResult, ItemAnalyticsResult, ItemStatsResult, LegacyAutoloadReport, - Listing, - ListingStats, SpendingsResult, UpdatePriceResult, UploadResult, @@ -42,7 +38,6 @@ ) __all__ = ( - "AccountSpendings", "ActionResult", "Ad", "AdItem", @@ -64,15 +59,12 @@ "AutoloadReportsResult", "AutoloadTreeNode", "AutoloadTreeResult", - "CallStats", "CallStat", "CallsStatsResult", "DomainObject", "ItemAnalyticsResult", "ItemStatsResult", "LegacyAutoloadReport", - "Listing", - "ListingStats", "SpendingsResult", "UpdatePriceResult", "UploadResult", diff --git a/avito/ads/domain.py b/avito/ads/domain.py index 603b5d5..f0b21ea 100644 --- a/avito/ads/domain.py +++ b/avito/ads/domain.py @@ -47,12 +47,12 @@ def _validate_non_empty_items(name: str, items: Sequence[object]) -> None: if not items: - raise ValidationError(f"`{name}` must contain at least one item.") + raise ValidationError(f"`{name}` должен содержать хотя бы один элемент.") def _validate_non_empty_string(name: str, value: str) -> None: if not value.strip(): - raise ValidationError(f"`{name}` must be a non-empty string.") + raise ValidationError(f"`{name}` не может быть пустой строкой.") def _validate_string_items(name: str, values: Sequence[str]) -> None: @@ -88,7 +88,7 @@ class DomainObject: class Ad(DomainObject): """Доменный объект объявления.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None def get(self) -> AdItem: @@ -136,21 +136,21 @@ def get_stats( ) def _require_item_id(self) -> int: - if self.resource_id is None: + if self.item_id is None: raise ValidationError("Для операции требуется `item_id`.") - return int(self.resource_id) + return int(self.item_id) def _require_ids(self) -> tuple[int, int]: - if self.resource_id is None or self.user_id is None: + if self.item_id is None or self.user_id is None: raise ValidationError("Для операции требуются `item_id` и `user_id`.") - return int(self.resource_id), int(self.user_id) + return int(self.item_id), int(self.user_id) @dataclass(slots=True, frozen=True) class AdStats(DomainObject): """Доменный объект статистики объявлений.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None def get_calls_stats( @@ -164,7 +164,7 @@ def get_calls_stats( user_id = self._require_user_id() resolved_item_ids = item_ids or ( - [int(self.resource_id)] if self.resource_id is not None else [] + [int(self.item_id)] if self.item_id is not None else [] ) return StatsClient(self.transport).get_calls_stats( user_id=user_id, @@ -185,7 +185,7 @@ def get_item_stats( user_id = self._require_user_id() resolved_item_ids = item_ids or ( - [int(self.resource_id)] if self.resource_id is not None else [] + [int(self.item_id)] if self.item_id is not None else [] ) return StatsClient(self.transport).get_item_stats( user_id=user_id, @@ -209,7 +209,7 @@ def get_item_analytics( user_id = self._require_user_id() resolved_item_ids = item_ids or ( - [int(self.resource_id)] if self.resource_id is not None else [] + [int(self.item_id)] if self.item_id is not None else [] ) return StatsClient(self.transport).get_item_analytics( user_id=user_id, @@ -233,7 +233,7 @@ def get_account_spendings( user_id = self._require_user_id() resolved_item_ids = item_ids or ( - [int(self.resource_id)] if self.resource_id is not None else [] + [int(self.item_id)] if self.item_id is not None else [] ) return StatsClient(self.transport).get_account_spendings( user_id=user_id, @@ -255,7 +255,7 @@ def _require_user_id(self) -> int: class AdPromotion(DomainObject): """Доменный объект продвижения объявления.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None def get_vas_prices( @@ -344,9 +344,9 @@ def apply_vas_direct( ) def _require_item_id(self) -> int: - if self.resource_id is None: + if self.item_id is None: raise ValidationError("Для операции требуется `item_id`.") - return int(self.resource_id) + return int(self.item_id) def _require_user_id(self) -> int: if self.user_id is None: @@ -361,7 +361,6 @@ def _require_ids(self) -> tuple[int, int]: class AutoloadProfile(DomainObject): """Доменный объект профиля автозагрузки.""" - resource_id: int | str | None = None user_id: int | str | None = None def get(self) -> AutoloadProfileSettings: @@ -404,7 +403,7 @@ def get_node_fields(self, *, node_slug: str) -> AutoloadFieldsResult: class AutoloadReport(DomainObject): """Доменный объект отчета автозагрузки.""" - resource_id: int | str | None = None + report_id: int | str | None = None user_id: int | str | None = None def get(self) -> AutoloadReportDetails: @@ -451,16 +450,16 @@ def get_items_info(self, *, item_ids: Sequence[int]) -> AutoloadReportItemsResul return AutoloadClient(self.transport).get_items_info(item_ids=list(item_ids)) def _require_report_id(self) -> int: - if self.resource_id is None: + if self.report_id is None: raise ValidationError("Для операции требуется `report_id`.") - return int(self.resource_id) + return int(self.report_id) @dataclass(slots=True, frozen=True) class AutoloadArchive(DomainObject): """Доменный объект архивных операций автозагрузки.""" - resource_id: int | str | None = None + report_id: int | str | None = None user_id: int | str | None = None def get_profile(self) -> AutoloadProfileSettings: @@ -495,9 +494,9 @@ def get_report(self) -> LegacyAutoloadReport: return AutoloadArchiveClient(self.transport).get_report(report_id=report_id) def _require_report_id(self) -> int: - if self.resource_id is None: + if self.report_id is None: raise ValidationError("Для операции требуется `report_id`.") - return int(self.resource_id) + return int(self.report_id) __all__ = ( diff --git a/avito/ads/models.py b/avito/ads/models.py index 2831565..43f9c0f 100644 --- a/avito/ads/models.py +++ b/avito/ads/models.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field -from avito.core.serialization import SerializableModel, enable_module_serialization +from avito.core.serialization import SerializableModel @dataclass(slots=True, frozen=True) @@ -155,7 +155,7 @@ class SpendingsResult(SerializableModel): @dataclass(slots=True, frozen=True) -class VasPrice: +class VasPrice(SerializableModel): """Цена и доступность услуги продвижения.""" code: str | None @@ -224,7 +224,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class AutoloadProfileSettings: +class AutoloadProfileSettings(SerializableModel): """Профиль пользователя автозагрузки.""" user_id: int | None @@ -275,7 +275,7 @@ class UploadResult(SerializableModel): @dataclass(slots=True, frozen=True) -class AutoloadField: +class AutoloadField(SerializableModel): """Поле категории автозагрузки.""" slug: str | None @@ -292,7 +292,7 @@ class AutoloadFieldsResult(SerializableModel): @dataclass(slots=True, frozen=True) -class AutoloadTreeNode: +class AutoloadTreeNode(SerializableModel): """Узел дерева категорий автозагрузки.""" slug: str | None @@ -315,7 +315,7 @@ class IdMappingResult(SerializableModel): @dataclass(slots=True, frozen=True) -class AutoloadReportSummary: +class AutoloadReportSummary(SerializableModel): """Краткая информация по отчету автозагрузки.""" report_id: int | None @@ -334,7 +334,7 @@ class AutoloadReportsResult(SerializableModel): @dataclass(slots=True, frozen=True) -class AutoloadReportItem: +class AutoloadReportItem(SerializableModel): """Объявление внутри отчета автозагрузки.""" item_id: int | None @@ -352,7 +352,7 @@ class AutoloadReportItemsResult(SerializableModel): @dataclass(slots=True, frozen=True) -class AutoloadFee: +class AutoloadFee(SerializableModel): """Списание по объявлению в отчете автозагрузки.""" item_id: int | None @@ -369,7 +369,7 @@ class AutoloadFeesResult(SerializableModel): @dataclass(slots=True, frozen=True) -class AutoloadReportDetails: +class AutoloadReportDetails(SerializableModel): """Детальная информация по отчету автозагрузки.""" report_id: int | None @@ -381,7 +381,7 @@ class AutoloadReportDetails: @dataclass(slots=True, frozen=True) -class LegacyAutoloadReport: +class LegacyAutoloadReport(SerializableModel): """Legacy-ответ автозагрузки.""" report_id: int | None @@ -396,14 +396,7 @@ class ActionResult(SerializableModel): message: str | None = None -Listing = AdItem -ListingStats = ItemStatsRecord -CallStats = CallStat -AccountSpendings = SpendingsResult - - __all__ = ( - "AccountSpendings", "ActionResult", "AdItem", "AdsListResult", @@ -422,7 +415,6 @@ class ActionResult(SerializableModel): "AutoloadReportsResult", "AutoloadTreeNode", "AutoloadTreeResult", - "CallStats", "CallStat", "CallsStatsRequest", "CallsStatsResult", @@ -432,8 +424,6 @@ class ActionResult(SerializableModel): "ItemStatsRequest", "ItemStatsResult", "LegacyAutoloadReport", - "Listing", - "ListingStats", "SpendingRecord", "SpendingsResult", "UpdatePriceRequest", @@ -445,5 +435,3 @@ class ActionResult(SerializableModel): "VasPricesRequest", "VasPricesResult", ) - -enable_module_serialization(globals()) diff --git a/avito/auth/provider.py b/avito/auth/provider.py index 1881985..816d539 100644 --- a/avito/auth/provider.py +++ b/avito/auth/provider.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from datetime import UTC, datetime -from typing import TYPE_CHECKING, Protocol +from typing import Protocol import httpx @@ -19,9 +19,6 @@ from avito.auth.settings import AuthSettings from avito.core.exceptions import AuthenticationError -if TYPE_CHECKING: - pass - class TokenFetcher(Protocol): """Контракт получения нового access token из внешнего источника.""" diff --git a/avito/auth/settings.py b/avito/auth/settings.py index 38747a8..3849a8d 100644 --- a/avito/auth/settings.py +++ b/avito/auth/settings.py @@ -19,9 +19,7 @@ class AuthSettings(BaseModel): "client_secret": ( "AVITO_AUTH__CLIENT_SECRET", "AVITO_CLIENT_SECRET", - "AVITO_SECRET", "CLIENT_SECRET", - "SECRET", ), "scope": ("AVITO_AUTH__SCOPE", "AVITO_SCOPE", "SCOPE"), "refresh_token": ( @@ -71,9 +69,7 @@ class AuthSettings(BaseModel): validation_alias=AliasChoices( "AVITO_AUTH__CLIENT_SECRET", "AVITO_CLIENT_SECRET", - "AVITO_SECRET", "CLIENT_SECRET", - "SECRET", ), ) scope: str | None = Field( diff --git a/avito/autoteka/domain.py b/avito/autoteka/domain.py index 7273ec6..df4d2ca 100644 --- a/avito/autoteka/domain.py +++ b/avito/autoteka/domain.py @@ -56,7 +56,7 @@ class DomainObject: class AutotekaVehicle(DomainObject): """Доменный объект превью, спецификаций, тизеров и каталога.""" - resource_id: int | str | None = None + vehicle_id: int | str | None = None user_id: int | str | None = None def resolve_catalog(self, *, request: CatalogResolveRequest) -> CatalogResolveResult: @@ -72,7 +72,7 @@ def create_preview_by_vin(self, *, request: VinRequest) -> AutotekaPreviewInfo: def get_preview(self, *, preview_id: int | str | None = None) -> AutotekaPreviewInfo: return PreviewClient(self.transport).get_preview( - preview_id=preview_id or self._require_resource_id("preview_id") + preview_id=preview_id or self._require_vehicle_id("preview_id") ) def create_preview_by_external_item( @@ -102,7 +102,7 @@ def get_specification_by_id( specification_id: int | str | None = None, ) -> AutotekaSpecificationInfo: return SpecificationsClient(self.transport).get_by_id( - specification_id=specification_id or self._require_resource_id("specification_id") + specification_id=specification_id or self._require_vehicle_id("specification_id") ) def create_teaser(self, *, request: TeaserCreateRequest) -> AutotekaTeaserInfo: @@ -110,20 +110,20 @@ def create_teaser(self, *, request: TeaserCreateRequest) -> AutotekaTeaserInfo: def get_teaser(self, *, teaser_id: int | str | None = None) -> AutotekaTeaserInfo: return TeaserClient(self.transport).get( - teaser_id=teaser_id or self._require_resource_id("teaser_id") + teaser_id=teaser_id or self._require_vehicle_id("teaser_id") ) - def _require_resource_id(self, field_name: str) -> str: - if self.resource_id is None: + def _require_vehicle_id(self, field_name: str) -> str: + if self.vehicle_id is None: raise ValidationError(f"Для операции требуется `{field_name}`.") - return str(self.resource_id) + return str(self.vehicle_id) @dataclass(slots=True, frozen=True) class AutotekaReport(DomainObject): """Доменный объект отчетов и пакетов Автотеки.""" - resource_id: int | str | None = None + report_id: int | str | None = None user_id: int | str | None = None def get_active_package(self) -> AutotekaPackageInfo: @@ -142,7 +142,7 @@ def list_reports(self) -> AutotekaReportsResult: def get_report(self, *, report_id: int | str | None = None) -> AutotekaReportInfo: return ReportClient(self.transport).get_report( - report_id=report_id or self._require_resource_id() + report_id=report_id or self._require_report_id() ) def create_sync_report_by_reg_number(self, *, request: RegNumberRequest) -> AutotekaReportInfo: @@ -151,17 +151,16 @@ def create_sync_report_by_reg_number(self, *, request: RegNumberRequest) -> Auto def create_sync_report_by_vin(self, *, request: VinRequest) -> AutotekaReportInfo: return ReportClient(self.transport).create_sync_report_by_vin(request) - def _require_resource_id(self) -> str: - if self.resource_id is None: + def _require_report_id(self) -> str: + if self.report_id is None: raise ValidationError("Для операции требуется `report_id`.") - return str(self.resource_id) + return str(self.report_id) @dataclass(slots=True, frozen=True) class AutotekaMonitoring(DomainObject): """Доменный объект мониторинга Автотеки.""" - resource_id: int | str | None = None user_id: int | str | None = None def create_monitoring_bucket_add( @@ -191,7 +190,7 @@ def get_monitoring_reg_actions( class AutotekaScoring(DomainObject): """Доменный объект скоринга рисков.""" - resource_id: int | str | None = None + scoring_id: int | str | None = None user_id: int | str | None = None def create_scoring_by_vehicle_id(self, *, request: VehicleIdRequest) -> AutotekaScoringInfo: @@ -199,20 +198,19 @@ def create_scoring_by_vehicle_id(self, *, request: VehicleIdRequest) -> Autoteka def get_scoring_by_id(self, *, scoring_id: int | str | None = None) -> AutotekaScoringInfo: return ScoringClient(self.transport).get_by_id( - scoring_id=scoring_id or self._require_resource_id() + scoring_id=scoring_id or self._require_scoring_id() ) - def _require_resource_id(self) -> str: - if self.resource_id is None: + def _require_scoring_id(self) -> str: + if self.scoring_id is None: raise ValidationError("Для операции требуется `scoring_id`.") - return str(self.resource_id) + return str(self.scoring_id) @dataclass(slots=True, frozen=True) class AutotekaValuation(DomainObject): """Доменный объект оценки автомобиля.""" - resource_id: int | str | None = None user_id: int | str | None = None def get_valuation_by_specification( diff --git a/avito/autoteka/models.py b/avito/autoteka/models.py index a603851..483b957 100644 --- a/avito/autoteka/models.py +++ b/avito/autoteka/models.py @@ -4,7 +4,7 @@ from dataclasses import dataclass -from avito.core.serialization import enable_module_serialization +from avito.core.serialization import SerializableModel @dataclass(slots=True, frozen=True) @@ -169,7 +169,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class CatalogFieldValue: +class CatalogFieldValue(SerializableModel): """Значение параметра автокаталога.""" value_id: str | None @@ -177,7 +177,7 @@ class CatalogFieldValue: @dataclass(slots=True, frozen=True) -class CatalogField: +class CatalogField(SerializableModel): """Параметр автокаталога.""" field_id: str | None @@ -187,14 +187,14 @@ class CatalogField: @dataclass(slots=True, frozen=True) -class CatalogResolveResult: +class CatalogResolveResult(SerializableModel): """Результат актуализации параметров автокаталога.""" items: list[CatalogField] @dataclass(slots=True, frozen=True) -class AutotekaLeadEvent: +class AutotekaLeadEvent(SerializableModel): """Событие сервиса Сигнал.""" event_id: str | None @@ -209,7 +209,7 @@ class AutotekaLeadEvent: @dataclass(slots=True, frozen=True) -class AutotekaLeadsResult: +class AutotekaLeadsResult(SerializableModel): """Список событий сервиса Сигнал.""" items: list[AutotekaLeadEvent] @@ -217,7 +217,7 @@ class AutotekaLeadsResult: @dataclass(slots=True, frozen=True) -class MonitoringInvalidVehicle: +class MonitoringInvalidVehicle(SerializableModel): """Невалидный идентификатор авто в запросах мониторинга.""" vehicle_id: str | None @@ -225,7 +225,7 @@ class MonitoringInvalidVehicle: @dataclass(slots=True, frozen=True) -class MonitoringBucketResult: +class MonitoringBucketResult(SerializableModel): """Результат изменения списка мониторинга.""" success: bool @@ -233,7 +233,7 @@ class MonitoringBucketResult: @dataclass(slots=True, frozen=True) -class MonitoringEvent: +class MonitoringEvent(SerializableModel): """Событие мониторинга регистрационных действий.""" vehicle_id: str | None @@ -248,7 +248,7 @@ class MonitoringEvent: @dataclass(slots=True, frozen=True) -class MonitoringEventsResult: +class MonitoringEventsResult(SerializableModel): """Список событий мониторинга.""" items: list[MonitoringEvent] @@ -258,7 +258,7 @@ class MonitoringEventsResult: @dataclass(slots=True, frozen=True) -class AutotekaPackageInfo: +class AutotekaPackageInfo(SerializableModel): """Информация о текущем пакете отчетов Автотеки.""" reports_total: int | None @@ -268,7 +268,7 @@ class AutotekaPackageInfo: @dataclass(slots=True, frozen=True) -class AutotekaPreviewInfo: +class AutotekaPreviewInfo(SerializableModel): """Информация о превью автомобиля.""" preview_id: str | None @@ -278,7 +278,7 @@ class AutotekaPreviewInfo: @dataclass(slots=True, frozen=True) -class AutotekaReportInfo: +class AutotekaReportInfo(SerializableModel): """Информация об отчете Автотеки.""" report_id: str | None @@ -290,14 +290,14 @@ class AutotekaReportInfo: @dataclass(slots=True, frozen=True) -class AutotekaReportsResult: +class AutotekaReportsResult(SerializableModel): """Список отчетов Автотеки.""" items: list[AutotekaReportInfo] @dataclass(slots=True, frozen=True) -class AutotekaScoringInfo: +class AutotekaScoringInfo(SerializableModel): """Информация о скоринге рисков.""" scoring_id: str | None @@ -306,7 +306,7 @@ class AutotekaScoringInfo: @dataclass(slots=True, frozen=True) -class AutotekaSpecificationInfo: +class AutotekaSpecificationInfo(SerializableModel): """Информация о запросе спецификации автомобиля.""" specification_id: str | None @@ -316,7 +316,7 @@ class AutotekaSpecificationInfo: @dataclass(slots=True, frozen=True) -class AutotekaTeaserInfo: +class AutotekaTeaserInfo(SerializableModel): """Информация о тизере Автотеки.""" teaser_id: str | None @@ -327,7 +327,7 @@ class AutotekaTeaserInfo: @dataclass(slots=True, frozen=True) -class AutotekaValuationInfo: +class AutotekaValuationInfo(SerializableModel): """Оценка стоимости автомобиля.""" status: str | None @@ -339,6 +339,3 @@ class AutotekaValuationInfo: mileage: int | None avg_price_with_condition: int | None avg_market_price: int | None - - -enable_module_serialization(globals()) diff --git a/avito/client/client.py b/avito/client/client.py index b2b0dac..28354e1 100644 --- a/avito/client/client.py +++ b/avito/client/client.py @@ -49,7 +49,18 @@ class AvitoClient: ``` """ - def __init__(self, settings: AvitoSettings | None = None) -> None: + def __init__( + self, + settings: AvitoSettings | None = None, + *, + client_id: str | None = None, + client_secret: str | None = None, + ) -> None: + if client_id is not None or client_secret is not None: + from avito.auth.settings import AuthSettings + + auth = AuthSettings(client_id=client_id, client_secret=client_secret) + settings = AvitoSettings(auth=auth) self.settings = (settings or AvitoSettings.from_env()).validate_required() self.auth_provider = self._build_auth_provider() self.transport = Transport(self.settings, auth_provider=self.auth_provider) @@ -111,51 +122,51 @@ def _build_auth_provider(self) -> AuthProvider: def account(self, user_id: int | str | None = None) -> Account: """Создает доменный объект аккаунта.""" - return Account(self.transport, resource_id=user_id, user_id=user_id) + return Account(self.transport, user_id=user_id) def account_hierarchy(self, user_id: int | str | None = None) -> AccountHierarchy: """Создает доменный объект иерархии аккаунта.""" - return AccountHierarchy(self.transport, resource_id=user_id, user_id=user_id) + return AccountHierarchy(self.transport, user_id=user_id) def ad(self, item_id: int | str | None = None, user_id: int | str | None = None) -> Ad: """Создает доменный объект объявления.""" - return Ad(self.transport, resource_id=item_id, user_id=user_id) + return Ad(self.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 ) -> AdStats: """Создает доменный объект статистики объявления.""" - return AdStats(self.transport, resource_id=item_id, user_id=user_id) + return AdStats(self.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 ) -> AdPromotion: """Создает доменный объект продвижения объявления.""" - return AdPromotion(self.transport, resource_id=item_id, user_id=user_id) + return AdPromotion(self.transport, item_id=item_id, user_id=user_id) def autoload_profile(self, user_id: int | str | None = None) -> AutoloadProfile: """Создает доменный объект профиля автозагрузки.""" - return AutoloadProfile(self.transport, resource_id=user_id, user_id=user_id) + return AutoloadProfile(self.transport, user_id=user_id) def autoload_report(self, report_id: int | str | None = None) -> AutoloadReport: """Создает доменный объект отчета автозагрузки.""" - return AutoloadReport(self.transport, resource_id=report_id) + return AutoloadReport(self.transport, report_id=report_id) def autoload_archive(self, report_id: int | str | None = None) -> AutoloadArchive: """Создает доменный объект архивных операций автозагрузки.""" - return AutoloadArchive(self.transport, resource_id=report_id) + return AutoloadArchive(self.transport, report_id=report_id) def chat(self, chat_id: int | str | None = None, *, user_id: int | str | None = None) -> Chat: """Создает доменный объект чата.""" - return Chat(self.transport, resource_id=chat_id, user_id=user_id) + return Chat(self.transport, chat_id=chat_id, user_id=user_id) def chat_message( self, @@ -166,8 +177,7 @@ def chat_message( ) -> ChatMessage: """Создает доменный объект сообщения чата.""" - resource_id = message_id if message_id is not None else chat_id - return ChatMessage(self.transport, resource_id=resource_id, user_id=user_id) + return ChatMessage(self.transport, chat_id=chat_id, message_id=message_id, user_id=user_id) def chat_webhook(self) -> ChatWebhook: """Создает доменный объект webhook мессенджера.""" @@ -179,87 +189,87 @@ def chat_media( ) -> ChatMedia: """Создает доменный объект медиа мессенджера.""" - return ChatMedia(self.transport, resource_id=media_id, user_id=user_id) + return ChatMedia(self.transport, user_id=user_id) def special_offer_campaign(self, campaign_id: int | str | None = None) -> SpecialOfferCampaign: """Создает доменный объект рассылки спецпредложений.""" - return SpecialOfferCampaign(self.transport, resource_id=campaign_id) + return SpecialOfferCampaign(self.transport, campaign_id=campaign_id) def promotion_order(self, order_id: int | str | None = None) -> PromotionOrder: """Создает доменный объект заявки на продвижение.""" - return PromotionOrder(self.transport, resource_id=order_id) + return PromotionOrder(self.transport, order_id=order_id) def bbip_promotion(self, item_id: int | str | None = None) -> BbipPromotion: """Создает доменный объект BBIP-продвижения.""" - return BbipPromotion(self.transport, resource_id=item_id) + return BbipPromotion(self.transport, item_id=item_id) def trx_promotion(self, item_id: int | str | None = None) -> TrxPromotion: """Создает доменный объект TrxPromo.""" - return TrxPromotion(self.transport, resource_id=item_id) + return TrxPromotion(self.transport, item_id=item_id) def cpa_auction(self, item_id: int | str | None = None) -> CpaAuction: """Создает доменный объект CPA-аукциона.""" - return CpaAuction(self.transport, resource_id=item_id) + return CpaAuction(self.transport, item_id=item_id) def target_action_pricing(self, item_id: int | str | None = None) -> TargetActionPricing: """Создает доменный объект цены целевого действия.""" - return TargetActionPricing(self.transport, resource_id=item_id) + return TargetActionPricing(self.transport, item_id=item_id) def autostrategy_campaign(self, campaign_id: int | str | None = None) -> AutostrategyCampaign: """Создает доменный объект автостратегии.""" - return AutostrategyCampaign(self.transport, resource_id=campaign_id) + return AutostrategyCampaign(self.transport, campaign_id=campaign_id) def order(self, order_id: int | str | None = None) -> Order: """Создает доменный объект заказа.""" - return Order(self.transport, resource_id=order_id) + return Order(self.transport) def order_label(self, task_id: int | str | None = None) -> OrderLabel: """Создает доменный объект этикетки заказа.""" - return OrderLabel(self.transport, resource_id=task_id) + return OrderLabel(self.transport, task_id=task_id) def delivery_order(self, order_id: int | str | None = None) -> DeliveryOrder: """Создает доменный объект доставки.""" - return DeliveryOrder(self.transport, resource_id=order_id) + return DeliveryOrder(self.transport) def sandbox_delivery(self, task_id: int | str | None = None) -> SandboxDelivery: """Создает доменный объект песочницы доставки.""" - return SandboxDelivery(self.transport, resource_id=task_id) + return SandboxDelivery(self.transport) def delivery_task(self, task_id: int | str | None = None) -> DeliveryTask: """Создает доменный объект задачи доставки.""" - return DeliveryTask(self.transport, resource_id=task_id) + return DeliveryTask(self.transport, task_id=task_id) def stock(self, stock_id: int | str | None = None) -> Stock: """Создает доменный объект остатков.""" - return Stock(self.transport, resource_id=stock_id) + return Stock(self.transport) def vacancy(self, vacancy_id: int | str | None = None) -> Vacancy: """Создает доменный объект вакансии.""" - return Vacancy(self.transport, resource_id=vacancy_id) + return Vacancy(self.transport, vacancy_id=vacancy_id) def application(self, application_id: int | str | None = None) -> Application: """Создает доменный объект отклика.""" - return Application(self.transport, resource_id=application_id) + return Application(self.transport) def resume(self, resume_id: int | str | None = None) -> Resume: """Создает доменный объект резюме.""" - return Resume(self.transport, resource_id=resume_id) + return Resume(self.transport, resume_id=resume_id) def job_webhook(self) -> JobWebhook: """Создает доменный объект webhook раздела Работа.""" @@ -269,57 +279,57 @@ def job_webhook(self) -> JobWebhook: def job_dictionary(self, dictionary_id: int | str | None = None) -> JobDictionary: """Создает доменный объект словаря Работа.""" - return JobDictionary(self.transport, resource_id=dictionary_id) + return JobDictionary(self.transport, dictionary_id=dictionary_id) def cpa_lead(self, lead_id: int | str | None = None) -> CpaLead: """Создает доменный объект CPA-лида.""" - return CpaLead(self.transport, resource_id=lead_id) + return CpaLead(self.transport) def cpa_chat(self, chat_id: int | str | None = None) -> CpaChat: """Создает доменный объект CPA-чата.""" - return CpaChat(self.transport, resource_id=chat_id) + return CpaChat(self.transport, action_id=chat_id) def cpa_call(self, call_id: int | str | None = None) -> CpaCall: """Создает доменный объект CPA-звонка.""" - return CpaCall(self.transport, resource_id=call_id) + return CpaCall(self.transport) def cpa_archive(self, call_id: int | str | None = None) -> CpaArchive: """Создает доменный объект архивных операций CPA.""" - return CpaArchive(self.transport, resource_id=call_id) + return CpaArchive(self.transport, call_id=call_id) def call_tracking_call(self, call_id: int | str | None = None) -> CallTrackingCall: """Создает доменный объект CallTracking.""" - return CallTrackingCall(self.transport, resource_id=call_id) + return CallTrackingCall(self.transport, call_id=call_id) def autoteka_vehicle(self, vehicle_id: int | str | None = None) -> AutotekaVehicle: """Создает доменный объект транспортного средства Автотеки.""" - return AutotekaVehicle(self.transport, resource_id=vehicle_id) + return AutotekaVehicle(self.transport, vehicle_id=vehicle_id) def autoteka_report(self, report_id: int | str | None = None) -> AutotekaReport: """Создает доменный объект отчета Автотеки.""" - return AutotekaReport(self.transport, resource_id=report_id) + return AutotekaReport(self.transport, report_id=report_id) def autoteka_monitoring(self, monitoring_id: int | str | None = None) -> AutotekaMonitoring: """Создает доменный объект мониторинга Автотеки.""" - return AutotekaMonitoring(self.transport, resource_id=monitoring_id) + return AutotekaMonitoring(self.transport) def autoteka_scoring(self, scoring_id: int | str | None = None) -> AutotekaScoring: """Создает доменный объект скоринга Автотеки.""" - return AutotekaScoring(self.transport, resource_id=scoring_id) + return AutotekaScoring(self.transport, scoring_id=scoring_id) def autoteka_valuation(self, valuation_id: int | str | None = None) -> AutotekaValuation: """Создает доменный объект оценки Автотеки.""" - return AutotekaValuation(self.transport, resource_id=valuation_id) + return AutotekaValuation(self.transport) def realty_listing( self, @@ -329,7 +339,7 @@ def realty_listing( ) -> RealtyListing: """Создает доменный объект объявления недвижимости.""" - return RealtyListing(self.transport, resource_id=item_id, user_id=user_id) + return RealtyListing(self.transport, item_id=item_id, user_id=user_id) def realty_booking( self, @@ -339,7 +349,7 @@ def realty_booking( ) -> RealtyBooking: """Создает доменный объект бронирования недвижимости.""" - return RealtyBooking(self.transport, resource_id=item_id, user_id=user_id) + return RealtyBooking(self.transport, item_id=item_id, user_id=user_id) def realty_pricing( self, @@ -349,7 +359,7 @@ def realty_pricing( ) -> RealtyPricing: """Создает доменный объект цен недвижимости.""" - return RealtyPricing(self.transport, resource_id=item_id, user_id=user_id) + return RealtyPricing(self.transport, item_id=item_id, user_id=user_id) def realty_analytics_report( self, @@ -359,27 +369,27 @@ def realty_analytics_report( ) -> RealtyAnalyticsReport: """Создает доменный объект аналитического отчета недвижимости.""" - return RealtyAnalyticsReport(self.transport, resource_id=item_id, user_id=user_id) + return RealtyAnalyticsReport(self.transport, item_id=item_id, user_id=user_id) def review(self, review_id: int | str | None = None) -> Review: """Создает доменный объект отзыва.""" - return Review(self.transport, resource_id=review_id) + return Review(self.transport) def review_answer(self, answer_id: int | str | None = None) -> ReviewAnswer: """Создает доменный объект ответа на отзыв.""" - return ReviewAnswer(self.transport, resource_id=answer_id) + return ReviewAnswer(self.transport, answer_id=answer_id) def rating_profile(self, profile_id: int | str | None = None) -> RatingProfile: """Создает доменный объект рейтингового профиля.""" - return RatingProfile(self.transport, resource_id=profile_id) + return RatingProfile(self.transport) def tariff(self, tariff_id: int | str | None = None) -> Tariff: """Создает доменный объект тарифа.""" - return Tariff(self.transport, resource_id=tariff_id) + return Tariff(self.transport, tariff_id=tariff_id) __all__ = ("AvitoClient",) diff --git a/avito/core/exceptions.py b/avito/core/exceptions.py index 645562c..8c3501e 100644 --- a/avito/core/exceptions.py +++ b/avito/core/exceptions.py @@ -78,12 +78,12 @@ class TransportError(AvitoError): """Сбой HTTP-транспорта до получения корректного ответа API.""" -class AuthorizationError(AvitoError): - """Ошибка авторизации или недостатка прав API.""" +class AuthenticationError(AvitoError): + """Ошибка аутентификации: неверные credentials или истёкший токен (HTTP 401).""" -class AuthenticationError(AuthorizationError): - """Совместимое имя ошибки аутентификации.""" +class AuthorizationError(AvitoError): + """Ошибка авторизации: недостаточно прав для операции (HTTP 403).""" class PermissionDeniedError(AuthorizationError): diff --git a/avito/core/mapping.py b/avito/core/mapping.py index 317f6e9..2a6ea2c 100644 --- a/avito/core/mapping.py +++ b/avito/core/mapping.py @@ -3,13 +3,10 @@ from __future__ import annotations from collections.abc import Callable, Mapping -from typing import TypeVar from avito.core.transport import Transport from avito.core.types import HttpMethod, RequestContext -ModelT = TypeVar("ModelT") - def request_public_model[ModelT]( transport: Transport, diff --git a/avito/core/serialization.py b/avito/core/serialization.py index 9f21f90..7ce9c62 100644 --- a/avito/core/serialization.py +++ b/avito/core/serialization.py @@ -5,8 +5,7 @@ from base64 import b64encode from collections.abc import Mapping, Sequence from dataclasses import fields, is_dataclass -from inspect import isclass -from typing import Any, cast +from typing import Any def _is_public_field(name: str) -> bool: @@ -49,18 +48,4 @@ def model_dump(self) -> dict[str, Any]: return self.to_dict() -def enable_module_serialization(namespace: Mapping[str, object]) -> None: - """Добавляет `to_dict()` / `model_dump()` всем dataclass-моделям модуля.""" - - module_name = namespace.get("__name__") - for value in namespace.values(): - if not isclass(value) or getattr(value, "__module__", None) != module_name: - continue - if not is_dataclass(value) or hasattr(value, "to_dict"): - continue - dynamic_value = cast(Any, value) - dynamic_value.to_dict = SerializableModel.to_dict - dynamic_value.model_dump = SerializableModel.model_dump - - -__all__ = ("SerializableModel", "enable_module_serialization") +__all__ = ("SerializableModel",) diff --git a/avito/cpa/domain.py b/avito/cpa/domain.py index e0be020..c44bf76 100644 --- a/avito/cpa/domain.py +++ b/avito/cpa/domain.py @@ -46,7 +46,6 @@ class DomainObject: class CpaLead(DomainObject): """Доменный объект CPA-лида и связанных lead-операций.""" - resource_id: int | str | None = None user_id: int | str | None = None def create_complaint_by_action_id( @@ -64,12 +63,12 @@ def get_balance_info(self) -> CpaBalanceInfo: class CpaChat(DomainObject): """Доменный объект CPA-чата.""" - resource_id: int | str | None = None + action_id: int | str | None = None user_id: int | str | None = None def get(self, *, action_id: int | str | None = None) -> CpaChatInfo: return CpaChatsClient(self.transport).get_by_action_id( - action_id=action_id or self._require_resource_id() + action_id=action_id or self._require_action_id() ) def list( @@ -90,17 +89,16 @@ def get_phones_info_from_chats( ) -> CpaPhonesResult: return CpaChatsClient(self.transport).get_phones_info(request) - def _require_resource_id(self) -> str: - if self.resource_id is None: - raise ValidationError("Для операции требуется `action_id` или `chat_id`.") - return str(self.resource_id) + 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 CpaCall(DomainObject): """Доменный объект CPA-звонка.""" - resource_id: int | str | None = None user_id: int | str | None = None def list(self, *, request: CpaCallsByTimeRequest) -> CpaCallsResult: @@ -114,12 +112,12 @@ def create_complaint(self, *, request: CpaCallComplaintRequest) -> CpaActionResu class CpaArchive(DomainObject): """Доменный объект архивных операций CPA.""" - resource_id: int | str | None = None + call_id: int | str | None = None user_id: int | str | None = None def get_call(self, *, call_id: int | str | None = None) -> CpaAudioRecord: return CpaArchiveClient(self.transport).get_record( - call_id=call_id or self._require_resource_id() + call_id=call_id or self._require_call_id() ) def get_balance_info(self) -> CpaBalanceInfo: @@ -128,22 +126,22 @@ def get_balance_info(self) -> CpaBalanceInfo: def get_call_by_id(self, *, request: CpaCallByIdRequest) -> CpaCallInfo: return CpaArchiveClient(self.transport).get_call_by_id(request) - def _require_resource_id(self) -> str: - if self.resource_id is None: + def _require_call_id(self) -> str: + if self.call_id is None: raise ValidationError("Для операции требуется `call_id`.") - return str(self.resource_id) + return str(self.call_id) @dataclass(slots=True, frozen=True) class CallTrackingCall(DomainObject): """Доменный объект CallTracking.""" - resource_id: int | str | None = None + call_id: int | str | None = None user_id: int | str | None = None def get(self, *, call_id: int | None = None) -> CallTrackingCallResponse: resolved_call_id = call_id or ( - int(self.resource_id) if self.resource_id is not None else None + int(self.call_id) if self.call_id is not None else None ) if resolved_call_id is None: raise ValidationError("Для операции требуется `call_id`.") @@ -156,13 +154,13 @@ def list(self, *, request: CallTrackingCallsRequest) -> CallTrackingCallsResult: def download(self, *, call_id: int | str | None = None) -> CallTrackingRecord: return CallTrackingClient(self.transport).get_record_by_call_id( - call_id=call_id or self._require_resource_id() + call_id=call_id or self._require_call_id() ) - def _require_resource_id(self) -> str: - if self.resource_id is None: + def _require_call_id(self) -> str: + if self.call_id is None: raise ValidationError("Для операции требуется `call_id`.") - return str(self.resource_id) + return str(self.call_id) __all__ = ("CallTrackingCall", "CpaArchive", "CpaCall", "CpaChat", "CpaLead", "DomainObject") diff --git a/avito/cpa/models.py b/avito/cpa/models.py index d3b1df6..91dd84f 100644 --- a/avito/cpa/models.py +++ b/avito/cpa/models.py @@ -7,7 +7,7 @@ from typing import Any from avito.core import BinaryResponse -from avito.core.serialization import SerializableModel, enable_module_serialization +from avito.core.serialization import SerializableModel @dataclass(slots=True, frozen=True) @@ -289,4 +289,3 @@ def model_dump(self) -> dict[str, Any]: return self.to_dict() -enable_module_serialization(globals()) diff --git a/avito/jobs/domain.py b/avito/jobs/domain.py index 95c9119..271c834 100644 --- a/avito/jobs/domain.py +++ b/avito/jobs/domain.py @@ -54,7 +54,7 @@ class DomainObject: class Vacancy(DomainObject): """Доменный объект вакансий.""" - resource_id: int | str | None = None + vacancy_id: int | str | None = None user_id: int | str | None = None def create(self, *, request: VacancyCreateRequest, version: int = 2) -> JobActionResult: @@ -74,17 +74,17 @@ def update( client = VacanciesClient(self.transport) if version == 1: return client.update_classic( - vacancy_id=vacancy_id or self._require_resource_id(), request=request + vacancy_id=vacancy_id or self._require_vacancy_id(), request=request ) return client.update( - vacancy_uuid=vacancy_uuid or self._require_resource_id(), request=request + vacancy_uuid=vacancy_uuid or self._require_vacancy_id(), request=request ) def delete( self, *, request: VacancyArchiveRequest, vacancy_id: int | str | None = None ) -> JobActionResult: return VacanciesClient(self.transport).archive( - vacancy_id=vacancy_id or self._require_resource_id(), + vacancy_id=vacancy_id or self._require_vacancy_id(), request=request, ) @@ -92,7 +92,7 @@ def prolongate( self, *, request: VacancyProlongateRequest, vacancy_id: int | str | None = None ) -> JobActionResult: return VacanciesClient(self.transport).prolongate( - vacancy_id=vacancy_id or self._require_resource_id(), + vacancy_id=vacancy_id or self._require_vacancy_id(), request=request, ) @@ -103,7 +103,7 @@ def get( self, *, vacancy_id: int | str | None = None, query: VacanciesQuery | None = None ) -> VacancyInfo: return VacanciesClient(self.transport).get_item( - vacancy_id=vacancy_id or self._require_resource_id(), + vacancy_id=vacancy_id or self._require_vacancy_id(), query=query, ) @@ -117,21 +117,20 @@ def update_auto_renewal( self, *, request: VacancyAutoRenewalRequest, vacancy_uuid: str | None = None ) -> JobActionResult: return VacanciesClient(self.transport).update_auto_renewal( - vacancy_uuid=vacancy_uuid or self._require_resource_id(), + vacancy_uuid=vacancy_uuid or self._require_vacancy_id(), request=request, ) - def _require_resource_id(self) -> str: - if self.resource_id is None: + def _require_vacancy_id(self) -> str: + if self.vacancy_id is None: raise ValidationError("Для операции требуется идентификатор вакансии.") - return str(self.resource_id) + return str(self.vacancy_id) @dataclass(slots=True, frozen=True) class Application(DomainObject): """Доменный объект откликов.""" - resource_id: int | str | None = None user_id: int | str | None = None def apply(self, *, request: ApplicationActionRequest) -> JobActionResult: @@ -161,7 +160,7 @@ def update(self, *, request: ApplicationViewedRequest) -> JobActionResult: class Resume(DomainObject): """Доменный объект резюме.""" - resource_id: int | str | None = None + resume_id: int | str | None = None user_id: int | str | None = None def list(self, *, query: ResumeSearchQuery | None = None) -> ResumesResult: @@ -169,25 +168,24 @@ def list(self, *, query: ResumeSearchQuery | None = None) -> ResumesResult: def get(self, *, resume_id: int | str | None = None) -> ResumeInfo: return ResumeClient(self.transport).get_item( - resume_id=str(resume_id or self._require_resource_id()) + resume_id=str(resume_id or self._require_resume_id()) ) def get_contacts(self, *, resume_id: int | str | None = None) -> ResumeContactInfo: return ResumeClient(self.transport).get_contacts( - resume_id=str(resume_id or self._require_resource_id()) + resume_id=str(resume_id or self._require_resume_id()) ) - def _require_resource_id(self) -> str: - if self.resource_id is None: + def _require_resume_id(self) -> str: + if self.resume_id is None: raise ValidationError("Для операции требуется `resume_id`.") - return str(self.resource_id) + return str(self.resume_id) @dataclass(slots=True, frozen=True) class JobWebhook(DomainObject): """Доменный объект webhook откликов.""" - resource_id: int | str | None = None user_id: int | str | None = None def get(self) -> JobWebhookInfo: @@ -207,7 +205,7 @@ def delete(self, *, url: str | None = None) -> JobActionResult: class JobDictionary(DomainObject): """Доменный объект словарей вакансий.""" - resource_id: int | str | None = None + dictionary_id: int | str | None = None user_id: int | str | None = None def list(self) -> JobDictionariesResult: @@ -215,13 +213,13 @@ def list(self) -> JobDictionariesResult: def get(self, *, dictionary_id: str | None = None) -> JobDictionaryValuesResult: return DictionariesClient(self.transport).get_dict_by_id( - dictionary_id=dictionary_id or self._require_resource_id() + dictionary_id=dictionary_id or self._require_dictionary_id() ) - def _require_resource_id(self) -> str: - if self.resource_id is None: + def _require_dictionary_id(self) -> str: + if self.dictionary_id is None: raise ValidationError("Для операции требуется `dictionary_id`.") - return str(self.resource_id) + return str(self.dictionary_id) __all__ = ("Application", "DomainObject", "JobDictionary", "JobWebhook", "Resume", "Vacancy") diff --git a/avito/jobs/models.py b/avito/jobs/models.py index b37d5e9..8e97adf 100644 --- a/avito/jobs/models.py +++ b/avito/jobs/models.py @@ -4,7 +4,7 @@ from dataclasses import dataclass -from avito.core.serialization import enable_module_serialization +from avito.core.serialization import SerializableModel @dataclass(slots=True, frozen=True) @@ -181,7 +181,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class JobActionResult: +class JobActionResult(SerializableModel): """Результат mutation-операции Jobs API.""" success: bool @@ -191,7 +191,7 @@ class JobActionResult: @dataclass(slots=True, frozen=True) -class ApplicationInfo: +class ApplicationInfo(SerializableModel): """Информация об отклике.""" id: str | None @@ -203,14 +203,14 @@ class ApplicationInfo: @dataclass(slots=True, frozen=True) -class ApplicationsResult: +class ApplicationsResult(SerializableModel): """Список откликов.""" items: list[ApplicationInfo] @dataclass(slots=True, frozen=True) -class ApplicationIdItem: +class ApplicationIdItem(SerializableModel): """Идентификатор отклика.""" id: str | None @@ -218,7 +218,7 @@ class ApplicationIdItem: @dataclass(slots=True, frozen=True) -class ApplicationIdsResult: +class ApplicationIdsResult(SerializableModel): """Постраничный список идентификаторов откликов.""" items: list[ApplicationIdItem] @@ -226,7 +226,7 @@ class ApplicationIdsResult: @dataclass(slots=True, frozen=True) -class ApplicationState: +class ApplicationState(SerializableModel): """Статус отклика.""" slug: str | None @@ -234,14 +234,14 @@ class ApplicationState: @dataclass(slots=True, frozen=True) -class ApplicationStatesResult: +class ApplicationStatesResult(SerializableModel): """Список возможных статусов откликов.""" items: list[ApplicationState] @dataclass(slots=True, frozen=True) -class ResumeInfo: +class ResumeInfo(SerializableModel): """Краткая или полная информация о резюме.""" id: str | None @@ -252,7 +252,7 @@ class ResumeInfo: @dataclass(slots=True, frozen=True) -class ResumesResult: +class ResumesResult(SerializableModel): """Результат поиска резюме.""" items: list[ResumeInfo] @@ -261,7 +261,7 @@ class ResumesResult: @dataclass(slots=True, frozen=True) -class ResumeContactInfo: +class ResumeContactInfo(SerializableModel): """Контакты соискателя.""" name: str | None @@ -270,7 +270,7 @@ class ResumeContactInfo: @dataclass(slots=True, frozen=True) -class VacancyInfo: +class VacancyInfo(SerializableModel): """Информация о вакансии.""" id: str | None @@ -281,7 +281,7 @@ class VacancyInfo: @dataclass(slots=True, frozen=True) -class VacanciesResult: +class VacanciesResult(SerializableModel): """Список вакансий.""" items: list[VacancyInfo] @@ -289,7 +289,7 @@ class VacanciesResult: @dataclass(slots=True, frozen=True) -class VacancyStatusInfo: +class VacancyStatusInfo(SerializableModel): """Статус публикации вакансии v2.""" id: str | None @@ -298,14 +298,14 @@ class VacancyStatusInfo: @dataclass(slots=True, frozen=True) -class VacancyStatusesResult: +class VacancyStatusesResult(SerializableModel): """Список статусов вакансий.""" items: list[VacancyStatusInfo] @dataclass(slots=True, frozen=True) -class JobWebhookInfo: +class JobWebhookInfo(SerializableModel): """Подписка webhook раздела Работа.""" url: str | None @@ -314,14 +314,14 @@ class JobWebhookInfo: @dataclass(slots=True, frozen=True) -class JobWebhooksResult: +class JobWebhooksResult(SerializableModel): """Список webhook-подписок.""" items: list[JobWebhookInfo] @dataclass(slots=True, frozen=True) -class JobDictionaryInfo: +class JobDictionaryInfo(SerializableModel): """Справочник вакансий.""" id: str | None @@ -329,14 +329,14 @@ class JobDictionaryInfo: @dataclass(slots=True, frozen=True) -class JobDictionariesResult: +class JobDictionariesResult(SerializableModel): """Список доступных словарей.""" items: list[JobDictionaryInfo] @dataclass(slots=True, frozen=True) -class JobDictionaryValue: +class JobDictionaryValue(SerializableModel): """Значение словаря вакансий.""" id: int | str | None @@ -345,10 +345,7 @@ class JobDictionaryValue: @dataclass(slots=True, frozen=True) -class JobDictionaryValuesResult: +class JobDictionaryValuesResult(SerializableModel): """Список значений словаря.""" items: list[JobDictionaryValue] - - -enable_module_serialization(globals()) diff --git a/avito/messenger/domain.py b/avito/messenger/domain.py index 589981e..30b6f62 100644 --- a/avito/messenger/domain.py +++ b/avito/messenger/domain.py @@ -44,7 +44,7 @@ class DomainObject: class Chat(DomainObject): """Доменный объект чата.""" - resource_id: int | str | None = None + chat_id: int | str | None = None user_id: int | str | None = None def get(self) -> ChatInfo: @@ -82,16 +82,17 @@ def _require_user_id(self) -> int: return int(self.user_id) def _require_chat_id(self) -> str: - if self.resource_id is None: + if self.chat_id is None: raise ValidationError("Для операции требуется `chat_id`.") - return str(self.resource_id) + return str(self.chat_id) @dataclass(slots=True, frozen=True) class ChatMessage(DomainObject): """Доменный объект сообщения чата.""" - resource_id: int | str | None = None + chat_id: int | str | None = None + message_id: int | str | None = None user_id: int | str | None = None def list(self, *, chat_id: str | None = None) -> MessagesResult: @@ -140,21 +141,20 @@ def _require_user_id(self) -> int: return int(self.user_id) def _require_chat_id(self) -> str: - if self.resource_id is None: + if self.chat_id is None: raise ValidationError("Для операции требуется `chat_id`.") - return str(self.resource_id) + return str(self.chat_id) def _require_message_id(self) -> str: - if self.resource_id is None: + if self.message_id is None: raise ValidationError("Для операции требуется `message_id`.") - return str(self.resource_id) + return str(self.message_id) @dataclass(slots=True, frozen=True) class ChatWebhook(DomainObject): """Доменный объект webhook мессенджера.""" - resource_id: int | str | None = None user_id: int | str | None = None def list(self) -> SubscriptionsResult: @@ -177,7 +177,6 @@ def subscribe(self, *, url: str, secret: str | None = None) -> WebhookActionResu class ChatMedia(DomainObject): """Доменный объект media-функций мессенджера.""" - resource_id: int | str | None = None user_id: int | str | None = None def get_voice_files(self) -> VoiceFilesResult: @@ -203,7 +202,7 @@ def _require_user_id(self) -> int: class SpecialOfferCampaign(DomainObject): """Доменный объект рассылки скидок и спецпредложений.""" - resource_id: int | str | None = None + campaign_id: int | str | None = None user_id: int | str | None = None def get_available(self, *, item_ids: list[int]) -> SpecialOfferAvailableResult: @@ -246,9 +245,9 @@ def get_tariff_info(self) -> TariffInfo: return SpecialOffersClient(self.transport).get_tariff_info() def _require_campaign_id(self) -> str: - if self.resource_id is None: + if self.campaign_id is None: raise ValidationError("Для операции требуется `campaign_id`.") - return str(self.resource_id) + return str(self.campaign_id) __all__ = ( diff --git a/avito/messenger/models.py b/avito/messenger/models.py index df059cc..338311d 100644 --- a/avito/messenger/models.py +++ b/avito/messenger/models.py @@ -5,11 +5,11 @@ from dataclasses import dataclass from typing import BinaryIO -from avito.core.serialization import enable_module_serialization +from avito.core.serialization import SerializableModel @dataclass(slots=True, frozen=True) -class ChatInfo: +class ChatInfo(SerializableModel): """Информация о чате.""" id: str | None @@ -20,7 +20,7 @@ class ChatInfo: @dataclass(slots=True, frozen=True) -class ChatsResult: +class ChatsResult(SerializableModel): """Список чатов.""" items: list[ChatInfo] @@ -62,7 +62,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class MessageInfo: +class MessageInfo(SerializableModel): """Информация о сообщении чата.""" id: str | None @@ -75,7 +75,7 @@ class MessageInfo: @dataclass(slots=True, frozen=True) -class MessagesResult: +class MessagesResult(SerializableModel): """Список сообщений чата.""" items: list[MessageInfo] @@ -83,7 +83,7 @@ class MessagesResult: @dataclass(slots=True, frozen=True) -class MessageActionResult: +class MessageActionResult(SerializableModel): """Результат операции с сообщением или чатом.""" success: bool @@ -92,7 +92,7 @@ class MessageActionResult: @dataclass(slots=True, frozen=True) -class VoiceFile: +class VoiceFile(SerializableModel): """Голосовое сообщение.""" id: str | None @@ -102,14 +102,14 @@ class VoiceFile: @dataclass(slots=True, frozen=True) -class VoiceFilesResult: +class VoiceFilesResult(SerializableModel): """Список голосовых сообщений.""" items: list[VoiceFile] @dataclass(slots=True, frozen=True) -class UploadImageResult: +class UploadImageResult(SerializableModel): """Результат загрузки изображения.""" image_id: str | None @@ -117,7 +117,7 @@ class UploadImageResult: @dataclass(slots=True, frozen=True) -class UploadImagesResult: +class UploadImagesResult(SerializableModel): """Список загруженных изображений.""" items: list[UploadImageResult] @@ -148,7 +148,7 @@ def to_files(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class SubscriptionInfo: +class SubscriptionInfo(SerializableModel): """Подписка webhook мессенджера.""" url: str | None @@ -157,7 +157,7 @@ class SubscriptionInfo: @dataclass(slots=True, frozen=True) -class SubscriptionsResult: +class SubscriptionsResult(SerializableModel): """Список webhook-подписок.""" items: list[SubscriptionInfo] @@ -193,7 +193,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class WebhookActionResult: +class WebhookActionResult(SerializableModel): """Результат операции с webhook.""" success: bool @@ -225,7 +225,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class SpecialOfferAvailableItem: +class SpecialOfferAvailableItem(SerializableModel): """Доступное объявление для рассылки спецпредложений.""" item_id: int | None @@ -234,7 +234,7 @@ class SpecialOfferAvailableItem: @dataclass(slots=True, frozen=True) -class SpecialOfferAvailableResult: +class SpecialOfferAvailableResult(SerializableModel): """Результат получения доступных объявлений.""" items: list[SpecialOfferAvailableItem] @@ -263,7 +263,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class MultiCreateSpecialOfferResult: +class MultiCreateSpecialOfferResult(SerializableModel): """Результат создания рассылки.""" campaign_id: str | None @@ -295,7 +295,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class SpecialOfferStatsResult: +class SpecialOfferStatsResult(SerializableModel): """Статистика рассылки.""" campaign_id: str | None @@ -305,7 +305,7 @@ class SpecialOfferStatsResult: @dataclass(slots=True, frozen=True) -class TariffInfo: +class TariffInfo(SerializableModel): """Информация о тарифе рассылок.""" price: float | None @@ -343,5 +343,3 @@ class TariffInfo: "VoiceFilesResult", "WebhookActionResult", ) - -enable_module_serialization(globals()) diff --git a/avito/orders/domain.py b/avito/orders/domain.py index c5fca5f..9edb7f2 100644 --- a/avito/orders/domain.py +++ b/avito/orders/domain.py @@ -73,7 +73,6 @@ class DomainObject: class Order(DomainObject): """Доменный объект заказа.""" - resource_id: int | str | None = None user_id: int | str | None = None def list(self) -> OrdersResult: @@ -110,7 +109,7 @@ def update_tracking_number(self, *, request: OrderTrackingNumberRequest) -> Orde class OrderLabel(DomainObject): """Доменный объект генерации и загрузки этикеток.""" - resource_id: int | str | None = None + task_id: int | str | None = None user_id: int | str | None = None def create(self, *, request: OrderLabelsRequest, extended: bool = False) -> LabelTaskResult: @@ -124,16 +123,15 @@ def download(self, *, task_id: str | None = None) -> LabelPdfResult: return LabelsClient(self.transport).get_download_label(task_id=resolved_task_id) def _require_task_id(self) -> str: - if self.resource_id is None: + if self.task_id is None: raise ValidationError("Для операции требуется `task_id`.") - return str(self.resource_id) + return str(self.task_id) @dataclass(slots=True, frozen=True) class DeliveryOrder(DomainObject): """Доменный объект production API доставки.""" - resource_id: int | str | None = None user_id: int | str | None = None def create_announcement(self, *, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: @@ -158,7 +156,6 @@ def create_change_parcel_result( class SandboxDelivery(DomainObject): """Доменный объект sandbox API доставки.""" - resource_id: int | str | None = None user_id: int | str | None = None def create_announcement(self, *, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: @@ -272,7 +269,7 @@ def get_sandbox_registered_parcel_id( class DeliveryTask(DomainObject): """Доменный объект задачи доставки.""" - resource_id: int | str | None = None + task_id: int | str | None = None user_id: int | str | None = None def get(self, *, task_id: str | None = None) -> DeliveryTaskInfo: @@ -280,16 +277,15 @@ def get(self, *, task_id: str | None = None) -> DeliveryTaskInfo: return DeliveryTasksClient(self.transport).get_task(task_id=resolved_task_id) def _require_task_id(self) -> str: - if self.resource_id is None: + if self.task_id is None: raise ValidationError("Для операции требуется `task_id`.") - return str(self.resource_id) + return str(self.task_id) @dataclass(slots=True, frozen=True) class Stock(DomainObject): """Доменный объект управления остатками.""" - resource_id: int | str | None = None user_id: int | str | None = None def get(self, *, request: StockInfoRequest) -> StockInfoResult: diff --git a/avito/orders/models.py b/avito/orders/models.py index 2807a64..f7cb664 100644 --- a/avito/orders/models.py +++ b/avito/orders/models.py @@ -7,7 +7,7 @@ from typing import Any from avito.core import BinaryResponse -from avito.core.serialization import enable_module_serialization +from avito.core.serialization import SerializableModel @dataclass(slots=True, frozen=True) @@ -1017,7 +1017,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class OrderSummary: +class OrderSummary(SerializableModel): """Краткая информация о заказе.""" order_id: str | None @@ -1028,7 +1028,7 @@ class OrderSummary: @dataclass(slots=True, frozen=True) -class OrdersResult: +class OrdersResult(SerializableModel): """Список заказов.""" items: list[OrderSummary] @@ -1036,7 +1036,7 @@ class OrdersResult: @dataclass(slots=True, frozen=True) -class OrderActionResult: +class OrderActionResult(SerializableModel): """Результат операции над заказом.""" success: bool @@ -1046,7 +1046,7 @@ class OrderActionResult: @dataclass(slots=True, frozen=True) -class CourierRange: +class CourierRange(SerializableModel): """Доступный интервал курьерской доставки.""" interval_id: str | None @@ -1056,7 +1056,7 @@ class CourierRange: @dataclass(slots=True, frozen=True) -class CourierRangesResult: +class CourierRangesResult(SerializableModel): """Список доступных интервалов курьерской доставки.""" items: list[CourierRange] @@ -1064,7 +1064,7 @@ class CourierRangesResult: @dataclass(slots=True, frozen=True) -class LabelTaskResult: +class LabelTaskResult(SerializableModel): """Результат генерации этикеток.""" task_id: str | None @@ -1097,7 +1097,7 @@ def model_dump(self) -> dict[str, Any]: @dataclass(slots=True, frozen=True) -class DeliveryEntityResult: +class DeliveryEntityResult(SerializableModel): """Результат операции delivery API.""" success: bool @@ -1109,7 +1109,7 @@ class DeliveryEntityResult: @dataclass(slots=True, frozen=True) -class DeliverySortingCenter: +class DeliverySortingCenter(SerializableModel): """Сортировочный центр доставки.""" sorting_center_id: str | None @@ -1118,14 +1118,14 @@ class DeliverySortingCenter: @dataclass(slots=True, frozen=True) -class DeliverySortingCentersResult: +class DeliverySortingCentersResult(SerializableModel): """Список сортировочных центров доставки.""" items: list[DeliverySortingCenter] @dataclass(slots=True, frozen=True) -class DeliveryTaskInfo: +class DeliveryTaskInfo(SerializableModel): """Информация о задаче доставки.""" task_id: str | None @@ -1134,7 +1134,7 @@ class DeliveryTaskInfo: @dataclass(slots=True, frozen=True) -class StockInfo: +class StockInfo(SerializableModel): """Информация по остаткам объявления.""" item_id: int | None @@ -1145,14 +1145,14 @@ class StockInfo: @dataclass(slots=True, frozen=True) -class StockInfoResult: +class StockInfoResult(SerializableModel): """Список текущих остатков.""" items: list[StockInfo] @dataclass(slots=True, frozen=True) -class StockUpdateItem: +class StockUpdateItem(SerializableModel): """Результат обновления остатков объявления.""" item_id: int | None @@ -1162,10 +1162,7 @@ class StockUpdateItem: @dataclass(slots=True, frozen=True) -class StockUpdateResult: +class StockUpdateResult(SerializableModel): """Результат изменения остатков.""" items: list[StockUpdateItem] - - -enable_module_serialization(globals()) diff --git a/avito/promotion/__init__.py b/avito/promotion/__init__.py index 4b986f8..a6bd7dc 100644 --- a/avito/promotion/__init__.py +++ b/avito/promotion/__init__.py @@ -35,8 +35,8 @@ CpaAuctionBidsResult, CreateItemBid, PromotionActionResult, - PromotionForecast, PromotionOrderError, + PromotionOrderInfo, PromotionOrdersResult, PromotionOrderStatusItem, PromotionOrderStatusResult, @@ -56,9 +56,6 @@ TrxCommissionsResult, TrxPromotionApplyItem, ) -from avito.promotion.models import ( - PromotionOrder as PromotionOrderModel, -) __all__ = ( "AutostrategyBudget", @@ -90,10 +87,9 @@ "CreateItemBid", "DomainObject", "PromotionActionResult", - "PromotionForecast", "PromotionOrder", - "PromotionOrderModel", "PromotionOrderError", + "PromotionOrderInfo", "PromotionOrderStatusItem", "PromotionOrderStatusResult", "PromotionOrdersResult", diff --git a/avito/promotion/domain.py b/avito/promotion/domain.py index 9bc5f0b..35e5240 100644 --- a/avito/promotion/domain.py +++ b/avito/promotion/domain.py @@ -23,6 +23,8 @@ BbipSuggestsResult, CampaignActionResult, CampaignDetailsResult, + CampaignListFilter, + CampaignOrderBy, CampaignsResult, CancelTrxPromotionRequest, CpaAuctionBidsResult, @@ -60,17 +62,17 @@ def _validate_non_empty_items(name: str, items: Sequence[object]) -> None: if not items: - raise ValidationError(f"`{name}` must contain at least one item.") + raise ValidationError(f"`{name}` должен содержать хотя бы один элемент.") def _validate_positive_int(name: str, value: int) -> None: if value <= 0: - raise ValidationError(f"`{name}` must be a positive integer.") + raise ValidationError(f"`{name}` должен быть положительным целым числом.") def _validate_non_empty_string(name: str, value: str) -> None: if not value.strip(): - raise ValidationError(f"`{name}` must be a non-empty string.") + raise ValidationError(f"`{name}` не может быть пустой строкой.") def _validate_string_items(name: str, values: Sequence[str]) -> None: @@ -106,7 +108,7 @@ class DomainObject: class PromotionOrder(DomainObject): """Доменный объект заявок и словарей promotion API.""" - resource_id: int | str | None = None + order_id: int | str | None = None user_id: int | str | None = None def get_service_dictionary(self) -> PromotionServiceDictionary: @@ -137,7 +139,7 @@ def get_order_status(self, *, order_ids: list[str] | None = None) -> PromotionOr """Получает статусы заявок на продвижение.""" resolved_order_ids = order_ids or ( - [str(self.resource_id)] if self.resource_id is not None else [] + [str(self.order_id)] if self.order_id is not None else [] ) if not resolved_order_ids: raise ValidationError("Для операции требуется хотя бы один `order_id`.") @@ -150,7 +152,7 @@ def get_order_status(self, *, order_ids: list[str] | None = None) -> PromotionOr class BbipPromotion(DomainObject): """Доменный объект BBIP-продвижения.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None def get_forecasts(self, *, items: list[BbipForecastRequestItem]) -> BbipForecastsResult: @@ -192,16 +194,16 @@ def get_suggests(self, *, item_ids: list[int] | None = None) -> BbipSuggestsResu ) def _resource_item_ids(self) -> list[int]: - if self.resource_id is None: + if self.item_id is None: raise ValidationError("Для операции требуется `item_id` или список `item_ids`.") - return [int(self.resource_id)] + return [int(self.item_id)] @dataclass(slots=True, frozen=True) class TrxPromotion(DomainObject): """Доменный объект TrxPromo.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None def apply( @@ -251,16 +253,16 @@ def get_commissions(self, *, item_ids: list[int] | None = None) -> TrxCommission ) def _resource_item_ids(self) -> list[int]: - if self.resource_id is None: + if self.item_id is None: raise ValidationError("Для операции требуется `item_id` или список `item_ids`.") - return [int(self.resource_id)] + return [int(self.item_id)] @dataclass(slots=True, frozen=True) class CpaAuction(DomainObject): """Доменный объект CPA-аукциона.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None def get_user_bids( @@ -286,7 +288,7 @@ def create_item_bids(self, *, items: list[CreateItemBid]) -> PromotionActionResu class TargetActionPricing(DomainObject): """Доменный объект цены целевого действия.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None def get_bids(self, *, item_id: int | None = None) -> TargetActionGetBidsResult: @@ -389,32 +391,96 @@ def update_manual( return TargetActionPriceClient(self.transport).update_manual_bid(request) def _require_item_id(self) -> int: - if self.resource_id is None: + if self.item_id is None: raise ValidationError("Для операции требуется `item_id`.") - return int(self.resource_id) + return int(self.item_id) @dataclass(slots=True, frozen=True) class AutostrategyCampaign(DomainObject): """Доменный объект кампаний автостратегии.""" - resource_id: int | str | None = None + campaign_id: int | str | None = None user_id: int | str | None = None - def create_budget(self, *, request: CreateAutostrategyBudgetRequest) -> AutostrategyBudget: + def create_budget( + self, + *, + campaign_type: str, + start_time: str | None = None, + finish_time: str | None = None, + items: list[int] | None = None, + ) -> AutostrategyBudget: """Рассчитывает бюджет кампании.""" - return AutostrategyClient(self.transport).create_budget(request) + return AutostrategyClient(self.transport).create_budget( + CreateAutostrategyBudgetRequest( + campaign_type=campaign_type, + start_time=start_time, + finish_time=finish_time, + items=items, + ) + ) - def create(self, *, request: CreateAutostrategyCampaignRequest) -> CampaignActionResult: + def create( + self, + *, + campaign_type: 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: str | None = None, + items: list[int] | None = None, + start_time: str | None = None, + ) -> CampaignActionResult: """Создает новую кампанию.""" - return AutostrategyClient(self.transport).create_campaign(request) + return AutostrategyClient(self.transport).create_campaign( + 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, + ) + ) - def update(self, *, request: UpdateAutostrategyCampaignRequest) -> CampaignActionResult: + 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: str | None = None, + items: list[int] | None = None, + start_time: str | None = None, + title: str | None = None, + ) -> CampaignActionResult: """Редактирует кампанию.""" - return AutostrategyClient(self.transport).edit_campaign(request) + return AutostrategyClient(self.transport).edit_campaign( + 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, + ) + ) def get(self, *, campaign_id: int | None = None) -> CampaignDetailsResult: """Получает полную информацию о кампании.""" @@ -435,11 +501,25 @@ def delete(self, *, version: int, campaign_id: int | None = None) -> CampaignAct ) ) - def list(self, *, request: ListAutostrategyCampaignsRequest | None = None) -> CampaignsResult: + def list( + self, + *, + limit: int = 100, + offset: int | None = None, + status_id: list[int] | None = None, + order_by: list[CampaignOrderBy] | None = None, + filter: CampaignListFilter | None = None, + ) -> CampaignsResult: """Получает список кампаний.""" return AutostrategyClient(self.transport).list_campaigns( - request or ListAutostrategyCampaignsRequest(limit=100) + ListAutostrategyCampaignsRequest( + limit=limit, + offset=offset, + status_id=status_id, + order_by=order_by, + filter=filter, + ) ) def get_stat(self, *, campaign_id: int | None = None) -> AutostrategyStat: @@ -450,9 +530,9 @@ def get_stat(self, *, campaign_id: int | None = None) -> AutostrategyStat: ) def _require_campaign_id(self) -> int: - if self.resource_id is None: + if self.campaign_id is None: raise ValidationError("Для операции требуется `campaign_id`.") - return int(self.resource_id) + return int(self.campaign_id) __all__ = ( diff --git a/avito/promotion/models.py b/avito/promotion/models.py index 8c8bd92..0bc4361 100644 --- a/avito/promotion/models.py +++ b/avito/promotion/models.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field -from avito.core.serialization import SerializableModel, enable_module_serialization +from avito.core.serialization import SerializableModel @dataclass(slots=True, frozen=True) @@ -897,7 +897,3 @@ class AutostrategyStatTotals(SerializableModel): views: int | None -PromotionOrder = PromotionOrderInfo -PromotionForecast = BbipForecast - -enable_module_serialization(globals()) diff --git a/avito/ratings/domain.py b/avito/ratings/domain.py index d06139e..792fad1 100644 --- a/avito/ratings/domain.py +++ b/avito/ratings/domain.py @@ -26,7 +26,6 @@ class DomainObject: class Review(DomainObject): """Доменный объект отзывов.""" - resource_id: int | str | None = None user_id: int | str | None = None def list(self, *, query: ReviewsQuery | None = None) -> ReviewsResult: @@ -37,7 +36,7 @@ def list(self, *, query: ReviewsQuery | None = None) -> ReviewsResult: class ReviewAnswer(DomainObject): """Доменный объект ответов на отзывы.""" - resource_id: int | str | None = None + answer_id: int | str | None = None user_id: int | str | None = None def create(self, *, review_id: int, text: str) -> ReviewAnswerInfo: @@ -51,16 +50,15 @@ def delete(self, *, answer_id: int | str | None = None) -> ReviewAnswerInfo: ) def _require_answer_id(self) -> str: - if self.resource_id is None: + if self.answer_id is None: raise ValidationError("Для операции требуется `answer_id`.") - return str(self.resource_id) + return str(self.answer_id) @dataclass(slots=True, frozen=True) class RatingProfile(DomainObject): """Доменный объект рейтингового профиля.""" - resource_id: int | str | None = None user_id: int | str | None = None def get(self) -> RatingProfileInfo: diff --git a/avito/ratings/models.py b/avito/ratings/models.py index 99590fa..b9a15cd 100644 --- a/avito/ratings/models.py +++ b/avito/ratings/models.py @@ -4,7 +4,7 @@ from dataclasses import dataclass -from avito.core.serialization import enable_module_serialization +from avito.core.serialization import SerializableModel @dataclass(slots=True, frozen=True) @@ -36,7 +36,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class ReviewInfo: +class ReviewInfo(SerializableModel): """Информация об отзыве пользователя.""" review_id: str | None @@ -49,7 +49,7 @@ class ReviewInfo: @dataclass(slots=True, frozen=True) -class ReviewsResult: +class ReviewsResult(SerializableModel): """Список отзывов пользователя.""" items: list[ReviewInfo] @@ -57,7 +57,7 @@ class ReviewsResult: @dataclass(slots=True, frozen=True) -class ReviewAnswerInfo: +class ReviewAnswerInfo(SerializableModel): """Информация об ответе на отзыв.""" answer_id: str | None = None @@ -66,13 +66,10 @@ class ReviewAnswerInfo: @dataclass(slots=True, frozen=True) -class RatingProfileInfo: +class RatingProfileInfo(SerializableModel): """Информация о рейтинговом профиле.""" is_enabled: bool score: float | None = None reviews_count: int | None = None reviews_with_score_count: int | None = None - - -enable_module_serialization(globals()) diff --git a/avito/realty/domain.py b/avito/realty/domain.py index 3e907dd..a0e40fb 100644 --- a/avito/realty/domain.py +++ b/avito/realty/domain.py @@ -30,7 +30,7 @@ class DomainObject: class RealtyListing(DomainObject): """Доменный объект объявления краткосрочной аренды.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None def get_intervals(self, *, request: RealtyIntervalsRequest) -> RealtyActionResult: @@ -45,16 +45,16 @@ def update_base_params( ) def _require_item_id(self) -> str: - if self.resource_id is None: + if self.item_id is None: raise ValidationError("Для операции требуется `item_id`.") - return str(self.resource_id) + return str(self.item_id) @dataclass(slots=True, frozen=True) class RealtyBooking(DomainObject): """Доменный объект бронирований недвижимости.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None def update_bookings_info( @@ -90,9 +90,9 @@ def list_realty_bookings( ) def _require_item_id(self) -> str: - if self.resource_id is None: + if self.item_id is None: raise ValidationError("Для операции требуется `item_id`.") - return str(self.resource_id) + return str(self.item_id) def _require_user_id(self) -> str: if self.user_id is None: @@ -104,7 +104,7 @@ def _require_user_id(self) -> str: class RealtyPricing(DomainObject): """Доменный объект цен краткосрочной аренды.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None def update_realty_prices( @@ -121,9 +121,9 @@ def update_realty_prices( ) def _require_item_id(self) -> str: - if self.resource_id is None: + if self.item_id is None: raise ValidationError("Для операции требуется `item_id`.") - return str(self.resource_id) + return str(self.item_id) def _require_user_id(self) -> str: if self.user_id is None: @@ -135,7 +135,7 @@ def _require_user_id(self) -> str: class RealtyAnalyticsReport(DomainObject): """Доменный объект аналитики по недвижимости.""" - resource_id: int | str | None = None + item_id: int | str | None = None user_id: int | str | None = None def get_market_price_correspondence( @@ -155,9 +155,9 @@ def get_report_for_classified(self, *, item_id: int | str | None = None) -> Real ) def _require_item_id(self) -> str: - if self.resource_id is None: + if self.item_id is None: raise ValidationError("Для операции требуется `item_id`.") - return str(self.resource_id) + return str(self.item_id) __all__ = ( diff --git a/avito/realty/models.py b/avito/realty/models.py index 6eb6a39..0dff6f1 100644 --- a/avito/realty/models.py +++ b/avito/realty/models.py @@ -4,7 +4,7 @@ from dataclasses import dataclass -from avito.core.serialization import SerializableModel, enable_module_serialization +from avito.core.serialization import SerializableModel @dataclass(slots=True, frozen=True) @@ -167,4 +167,3 @@ class RealtyAnalyticsInfo(SerializableModel): error_message: str | None = None -enable_module_serialization(globals()) diff --git a/avito/tariffs/domain.py b/avito/tariffs/domain.py index 855b31b..db2b31f 100644 --- a/avito/tariffs/domain.py +++ b/avito/tariffs/domain.py @@ -20,7 +20,7 @@ class DomainObject: class Tariff(DomainObject): """Доменный объект тарифа.""" - resource_id: int | str | None = None + tariff_id: int | str | None = None user_id: int | str | None = None def get_tariff_info(self) -> TariffInfo: diff --git a/avito/tariffs/models.py b/avito/tariffs/models.py index fcf0b33..85822df 100644 --- a/avito/tariffs/models.py +++ b/avito/tariffs/models.py @@ -4,11 +4,11 @@ from dataclasses import dataclass -from avito.core.serialization import enable_module_serialization +from avito.core.serialization import SerializableModel @dataclass(slots=True, frozen=True) -class TariffContractInfo: +class TariffContractInfo(SerializableModel): """Информация о текущем или запланированном тарифном контракте.""" level: str | None @@ -22,11 +22,8 @@ class TariffContractInfo: @dataclass(slots=True, frozen=True) -class TariffInfo: +class TariffInfo(SerializableModel): """Информация по текущему и запланированному тарифу.""" current: TariffContractInfo | None = None scheduled: TariffContractInfo | None = None - - -enable_module_serialization(globals()) From 2509d363827732c40a283ee67e3eaa63b980d8b7 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Mon, 20 Apr 2026 09:55:50 +0300 Subject: [PATCH 09/17] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D0=B3=D0=B0=D0=B9=D0=B4=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- avito/accounts/__init__.py | 11 ++--- avito/accounts/client.py | 85 ++++++++++++++++++++++++--------- avito/accounts/domain.py | 24 ++++------ avito/accounts/mappers.py | 12 ++--- avito/accounts/models.py | 8 ++-- avito/ads/__init__.py | 26 +++++----- avito/ads/client.py | 83 ++++++++++++++++---------------- avito/ads/domain.py | 95 +++++++++++-------------------------- avito/ads/mappers.py | 34 ++++++------- avito/ads/models.py | 34 ++++++------- avito/auth/settings.py | 27 +++-------- avito/autoteka/__init__.py | 2 - avito/autoteka/domain.py | 11 +---- avito/client/client.py | 26 +++++----- avito/config.py | 8 ++-- avito/core/__init__.py | 2 + avito/core/domain.py | 17 +++++++ avito/core/exceptions.py | 39 +++++++++------ avito/core/serialization.py | 7 +-- avito/core/validation.py | 40 ++++++++++++++++ avito/cpa/__init__.py | 3 +- avito/cpa/domain.py | 12 ++--- avito/jobs/__init__.py | 3 +- avito/jobs/domain.py | 12 ++--- avito/messenger/__init__.py | 2 - avito/messenger/domain.py | 11 +---- avito/messenger/mappers.py | 4 +- avito/messenger/models.py | 4 +- avito/orders/__init__.py | 2 - avito/orders/domain.py | 11 +---- avito/promotion/__init__.py | 12 ++--- avito/promotion/domain.py | 90 +++++++++++++---------------------- avito/promotion/models.py | 92 ++++++++++++++++------------------- avito/ratings/__init__.py | 3 +- avito/ratings/domain.py | 12 ++--- avito/realty/__init__.py | 2 - avito/realty/domain.py | 11 +---- avito/settings.py | 10 +++- avito/tariffs/__init__.py | 4 +- avito/tariffs/domain.py | 11 +---- 40 files changed, 427 insertions(+), 475 deletions(-) create mode 100644 avito/core/domain.py create mode 100644 avito/core/validation.py diff --git a/avito/accounts/__init__.py b/avito/accounts/__init__.py index eca83f9..e398550 100644 --- a/avito/accounts/__init__.py +++ b/avito/accounts/__init__.py @@ -1,35 +1,30 @@ """Пакет accounts.""" -from avito.accounts.domain import Account, AccountHierarchy, DomainObject +from avito.accounts.domain import Account, AccountHierarchy from avito.accounts.models import ( + AccountActionResult, AccountBalance, AccountProfile, - ActionResult, AhUserStatus, CompanyPhone, CompanyPhonesResult, Employee, EmployeeItem, - EmployeeItemsResult, EmployeesResult, OperationRecord, - OperationsHistoryResult, ) __all__ = ( "Account", + "AccountActionResult", "AccountBalance", "AccountHierarchy", "AccountProfile", - "ActionResult", "AhUserStatus", "CompanyPhone", "CompanyPhonesResult", - "DomainObject", "Employee", "EmployeeItem", - "EmployeeItemsResult", "EmployeesResult", "OperationRecord", - "OperationsHistoryResult", ) diff --git a/avito/accounts/client.py b/avito/accounts/client.py index b4d7a26..d7b5889 100644 --- a/avito/accounts/client.py +++ b/avito/accounts/client.py @@ -17,17 +17,18 @@ from avito.accounts.models import ( AccountBalance, AccountProfile, - ActionResult, + AccountActionResult, AhUserStatus, CompanyPhonesResult, + EmployeeItem, EmployeeItemLinkRequest, EmployeeItemsRequest, - EmployeeItemsResult, EmployeesResult, + OperationRecord, OperationsHistoryRequest, OperationsHistoryResult, ) -from avito.core import RequestContext, Transport +from avito.core import JsonPage, PaginatedList, Paginator, RequestContext, Transport from avito.core.mapping import request_public_model @@ -59,17 +60,37 @@ def get_balance(self, *, user_id: int) -> AccountBalance: mapper=map_account_balance, ) - def get_operations_history(self, request: OperationsHistoryRequest) -> OperationsHistoryResult: + def get_operations_history(self, request: OperationsHistoryRequest) -> PaginatedList[OperationRecord]: """Получает историю операций пользователя.""" - return request_public_model( - self.transport, - "POST", - "/core/v1/accounts/operations_history/", - context=RequestContext("accounts.get_operations_history", allow_retry=True), - mapper=map_operations_history, - json_body=request.to_payload(), - ) + page_size = request.limit or 25 + base_offset = request.offset or 0 + + def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[OperationRecord]: + current_page = page or 1 + current_offset = base_offset + (current_page - 1) * page_size + paged_request = OperationsHistoryRequest( + date_from=request.date_from, + date_to=request.date_to, + limit=page_size, + offset=current_offset, + ) + result = request_public_model( + self.transport, + "POST", + "/core/v1/accounts/operations_history/", + context=RequestContext("accounts.get_operations_history", allow_retry=True), + mapper=map_operations_history, + json_body=paged_request.to_payload(), + ) + return JsonPage( + items=result.operations, + total=result.total, + page=current_page, + per_page=page_size, + ) + + return Paginator(fetch_page).as_list(first_page=fetch_page(1, None)) @dataclass(slots=True) @@ -111,7 +132,7 @@ def list_company_phones(self) -> CompanyPhonesResult: mapper=map_company_phones, ) - def link_items(self, request: EmployeeItemLinkRequest) -> ActionResult: + def link_items(self, request: EmployeeItemLinkRequest) -> AccountActionResult: """Прикрепляет объявления к сотруднику.""" return request_public_model( @@ -123,17 +144,37 @@ def link_items(self, request: EmployeeItemLinkRequest) -> ActionResult: json_body=request.to_payload(), ) - def list_items_by_employee(self, request: EmployeeItemsRequest) -> EmployeeItemsResult: + def list_items_by_employee(self, request: EmployeeItemsRequest) -> PaginatedList[EmployeeItem]: """Получает список объявлений по сотруднику.""" - return request_public_model( - self.transport, - "POST", - "/listItemsByEmployeeIdV1", - context=RequestContext("accounts.hierarchy.list_items_by_employee", allow_retry=True), - mapper=map_employee_items, - json_body=request.to_payload(), - ) + page_size = request.limit or 25 + + def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[EmployeeItem]: + current_page = page or 1 + current_offset = (request.offset or 0) + (current_page - 1) * page_size + paged_request = EmployeeItemsRequest( + employee_id=request.employee_id, + limit=page_size, + offset=current_offset, + ) + result = request_public_model( + self.transport, + "POST", + "/listItemsByEmployeeIdV1", + context=RequestContext( + "accounts.hierarchy.list_items_by_employee", allow_retry=True + ), + mapper=map_employee_items, + json_body=paged_request.to_payload(), + ) + return JsonPage( + items=result.items, + total=result.total, + page=current_page, + per_page=page_size, + ) + + return Paginator(fetch_page).as_list(first_page=fetch_page(1, None)) __all__ = ("AccountsClient", "HierarchyClient") diff --git a/avito/accounts/domain.py b/avito/accounts/domain.py index 4e3f2a4..8aac08d 100644 --- a/avito/accounts/domain.py +++ b/avito/accounts/domain.py @@ -7,26 +7,20 @@ from avito.accounts.client import AccountsClient, HierarchyClient from avito.accounts.models import ( + AccountActionResult, AccountBalance, AccountProfile, - ActionResult, AhUserStatus, CompanyPhonesResult, + EmployeeItem, EmployeeItemLinkRequest, EmployeeItemsRequest, - EmployeeItemsResult, EmployeesResult, + OperationRecord, OperationsHistoryRequest, - OperationsHistoryResult, ) -from avito.core import Transport, ValidationError - - -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела accounts.""" - - transport: Transport +from avito.core import PaginatedList, ValidationError +from avito.core.domain import DomainObject @dataclass(slots=True, frozen=True) @@ -55,7 +49,7 @@ def get_operations_history( date_to: str | None = None, limit: int | None = None, offset: int | None = None, - ) -> OperationsHistoryResult: + ) -> PaginatedList[OperationRecord]: """Получает историю операций пользователя.""" return AccountsClient(self.transport).get_operations_history( @@ -95,7 +89,7 @@ def link_items( employee_id: int, item_ids: Sequence[int], source_employee_id: int | None = None, - ) -> ActionResult: + ) -> AccountActionResult: """Прикрепляет объявления к сотруднику.""" return HierarchyClient(self.transport).link_items( @@ -112,7 +106,7 @@ def list_items_by_employee( employee_id: int, limit: int | None = None, offset: int | None = None, - ) -> EmployeeItemsResult: + ) -> PaginatedList[EmployeeItem]: """Получает список объявлений сотрудника.""" return HierarchyClient(self.transport).list_items_by_employee( @@ -120,4 +114,4 @@ def list_items_by_employee( ) -__all__ = ("DomainObject", "Account", "AccountHierarchy") +__all__ = ("Account", "AccountHierarchy") diff --git a/avito/accounts/mappers.py b/avito/accounts/mappers.py index b037128..4891702 100644 --- a/avito/accounts/mappers.py +++ b/avito/accounts/mappers.py @@ -8,7 +8,7 @@ from avito.accounts.models import ( AccountBalance, AccountProfile, - ActionResult, + AccountActionResult, AhUserStatus, CompanyPhone, CompanyPhonesResult, @@ -79,7 +79,7 @@ def map_account_profile(payload: object) -> AccountProfile: data = _expect_mapping(payload) return AccountProfile( - id=_as_int(data, "id", "user_id"), + user_id=_as_int(data, "id", "user_id"), name=_as_str(data, "name", "title"), email=_as_str(data, "email"), phone=_as_str(data, "phone"), @@ -162,7 +162,7 @@ def map_company_phones(payload: object) -> CompanyPhonesResult: data = _expect_mapping(payload) items = [ CompanyPhone( - id=_as_int(item, "id", "phone_id", "phoneId"), + phone_id=_as_int(item, "id", "phone_id", "phoneId"), phone=_as_str(item, "phone", "value"), comment=_as_str(item, "comment", "description"), ) @@ -187,15 +187,15 @@ def map_employee_items(payload: object) -> EmployeeItemsResult: return EmployeeItemsResult(items=items, total=_as_int(data, "total", "count")) -def map_action_result(payload: object) -> ActionResult: +def map_action_result(payload: object) -> AccountActionResult: """Преобразует ответ мутационной операции в dataclass.""" if isinstance(payload, Mapping): data = cast(Payload, payload) success = bool(data.get("success", True)) message = _as_str(data, "message", "status") - return ActionResult(success=success, message=message) - return ActionResult(success=True) + return AccountActionResult(success=success, message=message) + return AccountActionResult(success=True) __all__ = ( diff --git a/avito/accounts/models.py b/avito/accounts/models.py index 5bf63c3..3dd4f9f 100644 --- a/avito/accounts/models.py +++ b/avito/accounts/models.py @@ -11,7 +11,7 @@ class AccountProfile(SerializableModel): """Профиль авторизованного пользователя.""" - id: int | None + user_id: int | None name: str | None email: str | None phone: str | None @@ -104,7 +104,7 @@ class EmployeesResult(SerializableModel): class CompanyPhone(SerializableModel): """Телефон компании.""" - id: int | None + phone_id: int | None phone: str | None comment: str | None @@ -179,7 +179,7 @@ class EmployeeItemsResult(SerializableModel): @dataclass(slots=True, frozen=True) -class ActionResult(SerializableModel): +class AccountActionResult(SerializableModel): """Результат мутационной операции accounts.""" success: bool @@ -187,9 +187,9 @@ class ActionResult(SerializableModel): __all__ = ( + "AccountActionResult", "AccountBalance", "AccountProfile", - "ActionResult", "AhUserStatus", "CompanyPhone", "CompanyPhonesResult", diff --git a/avito/ads/__init__.py b/avito/ads/__init__.py index 15b1664..c76d8e4 100644 --- a/avito/ads/__init__.py +++ b/avito/ads/__init__.py @@ -7,11 +7,10 @@ AutoloadArchive, AutoloadProfile, AutoloadReport, - DomainObject, ) from avito.ads.models import ( - ActionResult, - AdItem, + AccountSpendings, + AdsActionResult, AdsListResult, AutoloadFee, AutoloadFeesResult, @@ -25,12 +24,14 @@ AutoloadReportSummary, AutoloadTreeNode, AutoloadTreeResult, - CallStat, + CallStats, CallsStatsResult, ItemAnalyticsResult, ItemStatsResult, LegacyAutoloadReport, - SpendingsResult, + Listing, + ListingStats, + SpendingRecord, UpdatePriceResult, UploadResult, VasApplyResult, @@ -38,17 +39,17 @@ ) __all__ = ( - "ActionResult", + "AccountSpendings", "Ad", - "AdItem", + "AdsActionResult", + "AdsListResult", "AdPromotion", "AdStats", - "AdsListResult", + "AutoloadArchive", "AutoloadFee", "AutoloadFeesResult", "AutoloadField", "AutoloadFieldsResult", - "AutoloadArchive", "AutoloadProfile", "AutoloadProfileSettings", "AutoloadReport", @@ -59,13 +60,14 @@ "AutoloadReportsResult", "AutoloadTreeNode", "AutoloadTreeResult", - "CallStat", + "CallStats", "CallsStatsResult", - "DomainObject", "ItemAnalyticsResult", "ItemStatsResult", "LegacyAutoloadReport", - "SpendingsResult", + "Listing", + "ListingStats", + "SpendingRecord", "UpdatePriceResult", "UploadResult", "VasApplyResult", diff --git a/avito/ads/client.py b/avito/ads/client.py index 4db9ff0..039b322 100644 --- a/avito/ads/client.py +++ b/avito/ads/client.py @@ -26,8 +26,8 @@ map_vas_prices, ) from avito.ads.models import ( - ActionResult, - AdItem, + AccountSpendings, + AdsActionResult, AdsListResult, ApplyVasPackageRequest, ApplyVasRequest, @@ -38,6 +38,7 @@ AutoloadReportDetails, AutoloadReportItemsResult, AutoloadReportsResult, + AutoloadReportSummary, AutoloadTreeResult, CallsStatsRequest, CallsStatsResult, @@ -46,7 +47,7 @@ ItemStatsRequest, ItemStatsResult, LegacyAutoloadReport, - SpendingsResult, + Listing, UpdatePriceRequest, UpdatePriceResult, UploadByUrlRequest, @@ -54,7 +55,7 @@ VasPricesRequest, VasPricesResult, ) -from avito.core import JsonPage, Paginator, RequestContext, Transport, ValidationError +from avito.core import JsonPage, PaginatedList, Paginator, RequestContext, Transport, ValidationError from avito.core.mapping import request_public_model from avito.promotion.mappers import map_promotion_action from avito.promotion.models import PromotionActionResult @@ -66,7 +67,7 @@ class AdsClient: transport: Transport - def get_item(self, *, user_id: int, item_id: int) -> AdItem: + def get_item(self, *, user_id: int, item_id: int) -> Listing: """Получает одно объявление.""" return request_public_model( @@ -84,7 +85,7 @@ def list_items( status: str | None = None, limit: int | None = None, offset: int | None = None, - ) -> AdsListResult: + ) -> PaginatedList[Listing]: """Получает список объявлений пользователя.""" start_offset = offset if offset is not None else 0 if limit is not None else None @@ -103,33 +104,21 @@ def list_items( ) page_size = limit if limit and limit > 0 else len(result.items) resolved_offset = start_offset or 0 - - if ( - result.total is None - or page_size <= 0 - or resolved_offset + len(result.items) >= result.total - ): - return result - - start_page = resolved_offset // page_size + 1 - paginator = Paginator( + start_page = resolved_offset // page_size + 1 if page_size > 0 else 1 + first_page = JsonPage( + items=list(result.items), + total=result.total, + page=start_page, + per_page=page_size if page_size > 0 else None, + ) + return Paginator( lambda page, cursor: self._fetch_ads_page( page=page, user_id=user_id, status=status, page_size=page_size, ) - ) - paginated_items = paginator.as_list( - start_page=start_page, - first_page=JsonPage( - items=list(result.items), - total=result.total, - page=start_page, - per_page=page_size, - ), - ) - return AdsListResult(items=paginated_items, total=result.total) + ).as_list(start_page=start_page, first_page=first_page) def _fetch_ads_page( self, @@ -138,7 +127,7 @@ def _fetch_ads_page( user_id: int | None, status: str | None, page_size: int, - ) -> JsonPage[AdItem]: + ) -> JsonPage[Listing]: if page is None: raise ValidationError("Для операции требуется `page`.") @@ -218,7 +207,7 @@ def get_item_analytics(self, *, user_id: int, request: ItemStatsRequest) -> Item json_body=request.to_payload(), ) - def get_account_spendings(self, *, user_id: int, request: ItemStatsRequest) -> SpendingsResult: + def get_account_spendings(self, *, user_id: int, request: ItemStatsRequest) -> AccountSpendings: """Получает статистику расходов профиля.""" return request_public_model( @@ -335,7 +324,7 @@ def get_profile(self) -> AutoloadProfileSettings: mapper=map_autoload_profile, ) - def save_profile(self, request: AutoloadProfileUpdateRequest) -> ActionResult: + def save_profile(self, request: AutoloadProfileUpdateRequest) -> AdsActionResult: """Создает или редактирует профиль автозагрузки.""" return request_public_model( @@ -407,17 +396,31 @@ def get_avito_ids_by_ad_ids(self, *, ad_ids: list[int]) -> IdMappingResult: def list_reports( self, *, limit: int | None = None, offset: int | None = None - ) -> AutoloadReportsResult: + ) -> PaginatedList[AutoloadReportSummary]: """Получает список отчетов автозагрузки.""" - return request_public_model( - self.transport, - "GET", - "/autoload/v2/reports", - context=RequestContext("ads.autoload.list_reports"), - mapper=map_autoload_reports, - params={"limit": limit, "offset": offset}, - ) + page_size = limit or 25 + base_offset = offset or 0 + + 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 = request_public_model( + self.transport, + "GET", + "/autoload/v2/reports", + context=RequestContext("ads.autoload.list_reports"), + mapper=map_autoload_reports, + params={"limit": page_size, "offset": current_offset}, + ) + return JsonPage( + items=result.items, + total=result.total, + page=current_page, + per_page=page_size, + ) + + return Paginator(fetch_page).as_list(first_page=fetch_page(1, None)) def get_items_info(self, *, item_ids: list[int]) -> AutoloadReportItemsResult: """Получает объявления автозагрузки по ID.""" @@ -493,7 +496,7 @@ def get_profile(self) -> AutoloadProfileSettings: mapper=map_autoload_profile, ) - def save_profile(self, request: AutoloadProfileUpdateRequest) -> ActionResult: + def save_profile(self, request: AutoloadProfileUpdateRequest) -> AdsActionResult: """Создает или редактирует архивный профиль автозагрузки.""" return request_public_model( diff --git a/avito/ads/domain.py b/avito/ads/domain.py index f0b21ea..de4202e 100644 --- a/avito/ads/domain.py +++ b/avito/ads/domain.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Sequence from dataclasses import dataclass from avito.ads.client import ( @@ -13,9 +13,8 @@ VasClient, ) from avito.ads.models import ( - ActionResult, - AdItem, - AdsListResult, + AccountSpendings, + AdsActionResult, ApplyVasPackageRequest, ApplyVasRequest, AutoloadFeesResult, @@ -24,7 +23,7 @@ AutoloadProfileUpdateRequest, AutoloadReportDetails, AutoloadReportItemsResult, - AutoloadReportsResult, + AutoloadReportSummary, AutoloadTreeResult, CallsStatsRequest, CallsStatsResult, @@ -33,7 +32,7 @@ ItemStatsRequest, ItemStatsResult, LegacyAutoloadReport, - SpendingsResult, + Listing, UpdatePriceRequest, UpdatePriceResult, UploadByUrlRequest, @@ -41,49 +40,32 @@ VasPricesRequest, VasPricesResult, ) -from avito.core import Transport, ValidationError +from avito.core import PaginatedList, Transport, ValidationError +from avito.core.domain import DomainObject +from avito.core.validation import ( + validate_non_empty, + validate_non_empty_string, + validate_string_items, +) from avito.promotion.models import PromotionActionResult -def _validate_non_empty_items(name: str, items: Sequence[object]) -> None: - if not items: - raise ValidationError(f"`{name}` должен содержать хотя бы один элемент.") - - -def _validate_non_empty_string(name: str, value: str) -> None: - if not value.strip(): - raise ValidationError(f"`{name}` не может быть пустой строкой.") - - -def _validate_string_items(name: str, values: Sequence[str]) -> None: - _validate_non_empty_items(name, values) - for index, value in enumerate(values): - _validate_non_empty_string(f"{name}[{index}]", value) - - def _preview_result( *, action: str, - target: Mapping[str, object], - request_payload: Mapping[str, object], + target: dict[str, object], + request_payload: dict[str, object], ) -> PromotionActionResult: return PromotionActionResult( action=action, - target=dict(target), + target=target, status="preview", applied=False, - request_payload=dict(request_payload), + request_payload=request_payload, details={"validated": True}, ) -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела ads.""" - - transport: Transport - - @dataclass(slots=True, frozen=True) class Ad(DomainObject): """Доменный объект объявления.""" @@ -91,7 +73,7 @@ class Ad(DomainObject): item_id: int | str | None = None user_id: int | str | None = None - def get(self) -> AdItem: + def get(self) -> Listing: """Получает объявление по `item_id`.""" item_id, user_id = self._require_ids() @@ -99,7 +81,7 @@ def get(self) -> AdItem: def list( self, *, status: str | None = None, limit: int | None = None, offset: int | None = None - ) -> AdsListResult: + ) -> PaginatedList[Listing]: """Получает список объявлений.""" user_id = int(self.user_id) if self.user_id is not None else None @@ -115,26 +97,6 @@ def update_price(self, *, price: int | float) -> UpdatePriceResult: item_id=item_id, price=UpdatePriceRequest(price=price) ) - def get_stats( - self, - *, - date_from: str | None = None, - date_to: str | None = None, - fields: Sequence[str] | None = None, - ) -> ItemStatsResult: - """Получает статистику текущего объявления.""" - - item_id, user_id = self._require_ids() - return StatsClient(self.transport).get_item_stats( - user_id=user_id, - request=ItemStatsRequest( - item_ids=[item_id], - date_from=date_from, - date_to=date_to, - fields=list(fields or []), - ), - ) - def _require_item_id(self) -> int: if self.item_id is None: raise ValidationError("Для операции требуется `item_id`.") @@ -228,7 +190,7 @@ def get_account_spendings( date_from: str | None = None, date_to: str | None = None, fields: list[str] | None = None, - ) -> SpendingsResult: + ) -> AccountSpendings: """Получает статистику расходов профиля.""" user_id = self._require_user_id() @@ -278,7 +240,7 @@ def apply_vas( """Применяет дополнительные услуги к объявлению.""" item_id, user_id = self._require_ids() - _validate_string_items("codes", codes) + validate_string_items("codes", codes) request = ApplyVasRequest(codes=codes) request_payload = request.to_payload() target = {"item_id": item_id, "user_id": user_id} @@ -303,7 +265,7 @@ def apply_vas_package( """Применяет пакет дополнительных услуг.""" item_id, user_id = self._require_ids() - _validate_non_empty_string("package_code", package_code) + validate_non_empty_string("package_code", package_code) request = ApplyVasPackageRequest(package_code=package_code) request_payload = request.to_payload() target = {"item_id": item_id, "user_id": user_id} @@ -328,7 +290,7 @@ def apply_vas_direct( """Применяет услуги продвижения через прямой v2 endpoint.""" item_id = self._require_item_id() - _validate_string_items("codes", codes) + validate_string_items("codes", codes) request = ApplyVasRequest(codes=codes) request_payload = request.to_payload() target = {"item_id": item_id} @@ -374,7 +336,7 @@ def save( is_enabled: bool | None = None, email: str | None = None, callback_url: str | None = None, - ) -> ActionResult: + ) -> AdsActionResult: """Сохраняет профиль автозагрузки.""" return AutoloadClient(self.transport).save_profile( @@ -412,7 +374,9 @@ def get(self) -> AutoloadReportDetails: report_id = self._require_report_id() return AutoloadClient(self.transport).get_report(report_id=report_id) - def list(self, *, limit: int | None = None, offset: int | None = None) -> AutoloadReportsResult: + def list( + self, *, limit: int | None = None, offset: int | None = None + ) -> PaginatedList[AutoloadReportSummary]: """Получает список отчетов автозагрузки.""" return AutoloadClient(self.transport).list_reports(limit=limit, offset=offset) @@ -473,7 +437,7 @@ def save_profile( is_enabled: bool | None = None, email: str | None = None, callback_url: str | None = None, - ) -> ActionResult: + ) -> AdsActionResult: """Сохраняет архивный профиль автозагрузки.""" return AutoloadArchiveClient(self.transport).save_profile( @@ -500,11 +464,10 @@ def _require_report_id(self) -> int: __all__ = ( - "DomainObject", "Ad", - "AdStats", "AdPromotion", + "AdStats", + "AutoloadArchive", "AutoloadProfile", "AutoloadReport", - "AutoloadArchive", ) diff --git a/avito/ads/mappers.py b/avito/ads/mappers.py index 6752c0f..694630d 100644 --- a/avito/ads/mappers.py +++ b/avito/ads/mappers.py @@ -6,8 +6,8 @@ from typing import cast from avito.ads.models import ( - ActionResult, - AdItem, + AccountSpendings, + AdsActionResult, AdsListResult, AutoloadFee, AutoloadFeesResult, @@ -21,15 +21,15 @@ AutoloadReportSummary, AutoloadTreeNode, AutoloadTreeResult, + CallStats, CallsStatsResult, - CallStat, IdMappingResult, ItemAnalyticsResult, - ItemStatsRecord, ItemStatsResult, LegacyAutoloadReport, + Listing, + ListingStats, SpendingRecord, - SpendingsResult, UpdatePriceResult, UploadResult, VasApplyResult, @@ -91,12 +91,12 @@ def _bool(payload: Payload, *keys: str) -> bool | None: return None -def map_ad_item(payload: object) -> AdItem: +def map_ad_item(payload: object) -> Listing: """Преобразует объявление в dataclass.""" data = _expect_mapping(payload) - return AdItem( - id=_int(data, "id", "item_id", "itemId"), + return Listing( + item_id=_int(data, "id", "item_id", "itemId"), user_id=_int(data, "user_id", "userId"), title=_str(data, "title"), description=_str(data, "description"), @@ -130,7 +130,7 @@ def map_calls_stats(payload: object) -> CallsStatsResult: data = _expect_mapping(payload) items = [ - CallStat( + CallStats( item_id=_int(item, "item_id", "itemId", "id"), calls=_int(item, "calls", "total"), answered_calls=_int(item, "answered_calls", "answeredCalls"), @@ -141,8 +141,8 @@ def map_calls_stats(payload: object) -> CallsStatsResult: return CallsStatsResult(items=items) -def _map_item_stat(item: Payload) -> ItemStatsRecord: - return ItemStatsRecord( +def _map_item_stat(item: Payload) -> ListingStats: + return ListingStats( item_id=_int(item, "item_id", "itemId", "id"), views=_int(item, "views", "impressions"), contacts=_int(item, "contacts", "contacts_total", "contactsTotal"), @@ -169,7 +169,7 @@ def map_item_analytics(payload: object) -> ItemAnalyticsResult: ) -def map_spendings(payload: object) -> SpendingsResult: +def map_spendings(payload: object) -> AccountSpendings: """Преобразует статистику расходов.""" data = _expect_mapping(payload) @@ -184,7 +184,7 @@ def map_spendings(payload: object) -> SpendingsResult: total = _float(data, "total") if total is None: total = sum(item.amount for item in items if item.amount is not None) or None - return SpendingsResult(items=items, total=total) + return AccountSpendings(items=items, total=total) def map_vas_prices(payload: object) -> VasPricesResult: @@ -354,16 +354,16 @@ def map_autoload_fees(payload: object) -> AutoloadFeesResult: return AutoloadFeesResult(items=items, total=total) -def map_action_result(payload: object) -> ActionResult: - """Преобразует ответ мутационной операции.""" +def map_action_result(payload: object) -> AdsActionResult: + """Преобразует ответ мутационной операции ads.""" if isinstance(payload, Mapping): data = cast(Payload, payload) - return ActionResult( + return AdsActionResult( success=bool(data.get("success", True)), message=_str(data, "message", "status"), ) - return ActionResult(success=True) + return AdsActionResult(success=True) __all__ = ( diff --git a/avito/ads/models.py b/avito/ads/models.py index 43f9c0f..84540fe 100644 --- a/avito/ads/models.py +++ b/avito/ads/models.py @@ -8,10 +8,10 @@ @dataclass(slots=True, frozen=True) -class AdItem(SerializableModel): +class Listing(SerializableModel): """Объявление пользователя.""" - id: int | None + item_id: int | None user_id: int | None title: str | None description: str | None @@ -24,7 +24,7 @@ class AdItem(SerializableModel): class AdsListResult(SerializableModel): """Результат списка объявлений.""" - items: list[AdItem] + items: list[Listing] total: int | None = None @@ -72,7 +72,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class CallStat(SerializableModel): +class CallStats(SerializableModel): """Статистика звонков по объявлению.""" item_id: int | None @@ -85,7 +85,7 @@ class CallStat(SerializableModel): class CallsStatsResult(SerializableModel): """Статистика звонков по набору объявлений.""" - items: list[CallStat] + items: list[CallStats] @dataclass(slots=True, frozen=True) @@ -113,7 +113,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class ItemStatsRecord(SerializableModel): +class ListingStats(SerializableModel): """Статистические показатели объявления.""" item_id: int | None @@ -126,20 +126,20 @@ class ItemStatsRecord(SerializableModel): class ItemStatsResult(SerializableModel): """Статистика по списку объявлений.""" - items: list[ItemStatsRecord] + items: list[ListingStats] @dataclass(slots=True, frozen=True) class ItemAnalyticsResult(SerializableModel): """Аналитика по профилю или объявлениям.""" - items: list[ItemStatsRecord] + items: list[ListingStats] period: str | None = None @dataclass(slots=True, frozen=True) class SpendingRecord(SerializableModel): - """Запись статистики расходов.""" + """Запись статистики расходов по объявлению.""" item_id: int | None amount: float | None @@ -147,7 +147,7 @@ class SpendingRecord(SerializableModel): @dataclass(slots=True, frozen=True) -class SpendingsResult(SerializableModel): +class AccountSpendings(SerializableModel): """Статистика расходов профиля.""" items: list[SpendingRecord] @@ -389,16 +389,16 @@ class LegacyAutoloadReport(SerializableModel): @dataclass(slots=True, frozen=True) -class ActionResult(SerializableModel): - """Универсальный результат мутационной операции ads.""" +class AdsActionResult(SerializableModel): + """Результат мутационной операции ads.""" success: bool message: str | None = None __all__ = ( - "ActionResult", - "AdItem", + "AccountSpendings", + "AdsActionResult", "AdsListResult", "ApplyVasPackageRequest", "ApplyVasRequest", @@ -415,17 +415,17 @@ class ActionResult(SerializableModel): "AutoloadReportsResult", "AutoloadTreeNode", "AutoloadTreeResult", - "CallStat", + "CallStats", "CallsStatsRequest", "CallsStatsResult", "IdMappingResult", "ItemAnalyticsResult", - "ItemStatsRecord", "ItemStatsRequest", "ItemStatsResult", "LegacyAutoloadReport", + "Listing", + "ListingStats", "SpendingRecord", - "SpendingsResult", "UpdatePriceRequest", "UpdatePriceResult", "UploadByUrlRequest", diff --git a/avito/auth/settings.py b/avito/auth/settings.py index 3849a8d..5f8027f 100644 --- a/avito/auth/settings.py +++ b/avito/auth/settings.py @@ -15,43 +15,36 @@ class AuthSettings(BaseModel): """Единственный публичный контракт OAuth-конфигурации SDK.""" ENV_ALIASES: ClassVar[dict[str, tuple[str, ...]]] = { - "client_id": ("AVITO_AUTH__CLIENT_ID", "AVITO_CLIENT_ID", "CLIENT_ID"), + "client_id": ("AVITO_AUTH__CLIENT_ID", "AVITO_CLIENT_ID"), "client_secret": ( "AVITO_AUTH__CLIENT_SECRET", "AVITO_CLIENT_SECRET", - "CLIENT_SECRET", ), - "scope": ("AVITO_AUTH__SCOPE", "AVITO_SCOPE", "SCOPE"), + "scope": ("AVITO_AUTH__SCOPE", "AVITO_SCOPE"), "refresh_token": ( "AVITO_AUTH__REFRESH_TOKEN", "AVITO_REFRESH_TOKEN", - "REFRESH_TOKEN", ), - "token_url": ("AVITO_AUTH__TOKEN_URL", "AVITO_TOKEN_URL", "TOKEN_URL"), + "token_url": ("AVITO_AUTH__TOKEN_URL", "AVITO_TOKEN_URL"), "alternate_token_url": ( "AVITO_AUTH__ALTERNATE_TOKEN_URL", "AVITO_ALTERNATE_TOKEN_URL", - "ALTERNATE_TOKEN_URL", ), "autoteka_token_url": ( "AVITO_AUTH__AUTOTEKA_TOKEN_URL", "AVITO_AUTOTEKA_TOKEN_URL", - "AUTOTEKA_TOKEN_URL", ), "autoteka_client_id": ( "AVITO_AUTH__AUTOTEKA_CLIENT_ID", "AVITO_AUTOTEKA_CLIENT_ID", - "AUTOTEKA_CLIENT_ID", ), "autoteka_client_secret": ( "AVITO_AUTH__AUTOTEKA_CLIENT_SECRET", "AVITO_AUTOTEKA_CLIENT_SECRET", - "AUTOTEKA_CLIENT_SECRET", ), "autoteka_scope": ( "AVITO_AUTH__AUTOTEKA_SCOPE", "AVITO_AUTOTEKA_SCOPE", - "AUTOTEKA_SCOPE", ), } @@ -62,36 +55,34 @@ class AuthSettings(BaseModel): client_id: str | None = Field( default=None, - validation_alias=AliasChoices("AVITO_AUTH__CLIENT_ID", "AVITO_CLIENT_ID", "CLIENT_ID"), + validation_alias=AliasChoices("AVITO_AUTH__CLIENT_ID", "AVITO_CLIENT_ID"), ) client_secret: str | None = Field( default=None, validation_alias=AliasChoices( "AVITO_AUTH__CLIENT_SECRET", "AVITO_CLIENT_SECRET", - "CLIENT_SECRET", ), ) scope: str | None = Field( default=None, - validation_alias=AliasChoices("AVITO_AUTH__SCOPE", "AVITO_SCOPE", "SCOPE"), + validation_alias=AliasChoices("AVITO_AUTH__SCOPE", "AVITO_SCOPE"), ) refresh_token: str | None = Field( default=None, validation_alias=AliasChoices( - "AVITO_AUTH__REFRESH_TOKEN", "AVITO_REFRESH_TOKEN", "REFRESH_TOKEN" + "AVITO_AUTH__REFRESH_TOKEN", "AVITO_REFRESH_TOKEN" ), ) token_url: str = Field( default="/token", - validation_alias=AliasChoices("AVITO_AUTH__TOKEN_URL", "AVITO_TOKEN_URL", "TOKEN_URL"), + validation_alias=AliasChoices("AVITO_AUTH__TOKEN_URL", "AVITO_TOKEN_URL"), ) alternate_token_url: str = Field( default="/token", validation_alias=AliasChoices( "AVITO_AUTH__ALTERNATE_TOKEN_URL", "AVITO_ALTERNATE_TOKEN_URL", - "ALTERNATE_TOKEN_URL", ), ) autoteka_token_url: str = Field( @@ -99,7 +90,6 @@ class AuthSettings(BaseModel): validation_alias=AliasChoices( "AVITO_AUTH__AUTOTEKA_TOKEN_URL", "AVITO_AUTOTEKA_TOKEN_URL", - "AUTOTEKA_TOKEN_URL", ), ) autoteka_client_id: str | None = Field( @@ -107,7 +97,6 @@ class AuthSettings(BaseModel): validation_alias=AliasChoices( "AVITO_AUTH__AUTOTEKA_CLIENT_ID", "AVITO_AUTOTEKA_CLIENT_ID", - "AUTOTEKA_CLIENT_ID", ), ) autoteka_client_secret: str | None = Field( @@ -115,7 +104,6 @@ class AuthSettings(BaseModel): validation_alias=AliasChoices( "AVITO_AUTH__AUTOTEKA_CLIENT_SECRET", "AVITO_AUTOTEKA_CLIENT_SECRET", - "AUTOTEKA_CLIENT_SECRET", ), ) autoteka_scope: str | None = Field( @@ -123,7 +111,6 @@ class AuthSettings(BaseModel): validation_alias=AliasChoices( "AVITO_AUTH__AUTOTEKA_SCOPE", "AVITO_AUTOTEKA_SCOPE", - "AUTOTEKA_SCOPE", ), ) diff --git a/avito/autoteka/__init__.py b/avito/autoteka/__init__.py index 437c091..786fd3c 100644 --- a/avito/autoteka/__init__.py +++ b/avito/autoteka/__init__.py @@ -6,7 +6,6 @@ AutotekaScoring, AutotekaValuation, AutotekaVehicle, - DomainObject, ) from avito.autoteka.models import ( AutotekaLeadEvent, @@ -61,7 +60,6 @@ "CatalogFieldValue", "CatalogResolveResult", "CatalogResolveRequest", - "DomainObject", "ExternalItemPreviewRequest", "ItemIdRequest", "LeadsRequest", diff --git a/avito/autoteka/domain.py b/avito/autoteka/domain.py index df4d2ca..d766087 100644 --- a/avito/autoteka/domain.py +++ b/avito/autoteka/domain.py @@ -42,14 +42,8 @@ VehicleIdRequest, VinRequest, ) -from avito.core import Transport, ValidationError - - -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела autoteka.""" - - transport: Transport +from avito.core import ValidationError +from avito.core.domain import DomainObject @dataclass(slots=True, frozen=True) @@ -225,5 +219,4 @@ def get_valuation_by_specification( "AutotekaScoring", "AutotekaValuation", "AutotekaVehicle", - "DomainObject", ) diff --git a/avito/client/client.py b/avito/client/client.py index 28354e1..0deb889 100644 --- a/avito/client/client.py +++ b/avito/client/client.py @@ -184,9 +184,7 @@ def chat_webhook(self) -> ChatWebhook: return ChatWebhook(self.transport) - def chat_media( - self, media_id: int | str | None = None, *, user_id: int | str | None = None - ) -> ChatMedia: + def chat_media(self, *, user_id: int | str | None = None) -> ChatMedia: """Создает доменный объект медиа мессенджера.""" return ChatMedia(self.transport, user_id=user_id) @@ -226,7 +224,7 @@ def autostrategy_campaign(self, campaign_id: int | str | None = None) -> Autostr return AutostrategyCampaign(self.transport, campaign_id=campaign_id) - def order(self, order_id: int | str | None = None) -> Order: + def order(self) -> Order: """Создает доменный объект заказа.""" return Order(self.transport) @@ -236,12 +234,12 @@ def order_label(self, task_id: int | str | None = None) -> OrderLabel: return OrderLabel(self.transport, task_id=task_id) - def delivery_order(self, order_id: int | str | None = None) -> DeliveryOrder: + def delivery_order(self) -> DeliveryOrder: """Создает доменный объект доставки.""" return DeliveryOrder(self.transport) - def sandbox_delivery(self, task_id: int | str | None = None) -> SandboxDelivery: + def sandbox_delivery(self) -> SandboxDelivery: """Создает доменный объект песочницы доставки.""" return SandboxDelivery(self.transport) @@ -251,7 +249,7 @@ def delivery_task(self, task_id: int | str | None = None) -> DeliveryTask: return DeliveryTask(self.transport, task_id=task_id) - def stock(self, stock_id: int | str | None = None) -> Stock: + def stock(self) -> Stock: """Создает доменный объект остатков.""" return Stock(self.transport) @@ -261,7 +259,7 @@ def vacancy(self, vacancy_id: int | str | None = None) -> Vacancy: return Vacancy(self.transport, vacancy_id=vacancy_id) - def application(self, application_id: int | str | None = None) -> Application: + def application(self) -> Application: """Создает доменный объект отклика.""" return Application(self.transport) @@ -281,7 +279,7 @@ def job_dictionary(self, dictionary_id: int | str | None = None) -> JobDictionar return JobDictionary(self.transport, dictionary_id=dictionary_id) - def cpa_lead(self, lead_id: int | str | None = None) -> CpaLead: + def cpa_lead(self) -> CpaLead: """Создает доменный объект CPA-лида.""" return CpaLead(self.transport) @@ -291,7 +289,7 @@ def cpa_chat(self, chat_id: int | str | None = None) -> CpaChat: return CpaChat(self.transport, action_id=chat_id) - def cpa_call(self, call_id: int | str | None = None) -> CpaCall: + def cpa_call(self) -> CpaCall: """Создает доменный объект CPA-звонка.""" return CpaCall(self.transport) @@ -316,7 +314,7 @@ def autoteka_report(self, report_id: int | str | None = None) -> AutotekaReport: return AutotekaReport(self.transport, report_id=report_id) - def autoteka_monitoring(self, monitoring_id: int | str | None = None) -> AutotekaMonitoring: + def autoteka_monitoring(self) -> AutotekaMonitoring: """Создает доменный объект мониторинга Автотеки.""" return AutotekaMonitoring(self.transport) @@ -326,7 +324,7 @@ def autoteka_scoring(self, scoring_id: int | str | None = None) -> AutotekaScori return AutotekaScoring(self.transport, scoring_id=scoring_id) - def autoteka_valuation(self, valuation_id: int | str | None = None) -> AutotekaValuation: + def autoteka_valuation(self) -> AutotekaValuation: """Создает доменный объект оценки Автотеки.""" return AutotekaValuation(self.transport) @@ -371,7 +369,7 @@ def realty_analytics_report( return RealtyAnalyticsReport(self.transport, item_id=item_id, user_id=user_id) - def review(self, review_id: int | str | None = None) -> Review: + def review(self) -> Review: """Создает доменный объект отзыва.""" return Review(self.transport) @@ -381,7 +379,7 @@ def review_answer(self, answer_id: int | str | None = None) -> ReviewAnswer: return ReviewAnswer(self.transport, answer_id=answer_id) - def rating_profile(self, profile_id: int | str | None = None) -> RatingProfile: + def rating_profile(self) -> RatingProfile: """Создает доменный объект рейтингового профиля.""" return RatingProfile(self.transport) diff --git a/avito/config.py b/avito/config.py index dc30379..72a1e9a 100644 --- a/avito/config.py +++ b/avito/config.py @@ -25,8 +25,8 @@ class AvitoSettings(BaseModel): """Единственный публичный контракт конфигурации SDK.""" ENV_ALIASES: ClassVar[dict[str, tuple[str, ...]]] = { - "base_url": ("AVITO_BASE_URL", "BASE_URL"), - "user_id": ("AVITO_USER_ID", "USER_ID"), + "base_url": ("AVITO_BASE_URL",), + "user_id": ("AVITO_USER_ID",), } model_config = ConfigDict( @@ -36,11 +36,11 @@ class AvitoSettings(BaseModel): base_url: str = Field( default="https://api.avito.ru", - validation_alias=AliasChoices("BASE_URL", "AVITO_BASE_URL"), + validation_alias=AliasChoices("AVITO_BASE_URL"), ) user_id: int | None = Field( default=None, - validation_alias=AliasChoices("USER_ID", "AVITO_USER_ID"), + validation_alias=AliasChoices("AVITO_USER_ID"), ) auth: AuthSettings = Field(default_factory=AuthSettings) timeouts: ApiTimeouts = Field(default_factory=_default_timeouts) diff --git a/avito/core/__init__.py b/avito/core/__init__.py index 6fb173e..928fd25 100644 --- a/avito/core/__init__.py +++ b/avito/core/__init__.py @@ -1,5 +1,6 @@ """Пакет общей инфраструктуры SDK.""" +from avito.core.domain import DomainObject from avito.core.exceptions import ( AuthenticationError, AuthorizationError, @@ -38,6 +39,7 @@ "ClientError", "ConfigurationError", "ConflictError", + "DomainObject", "JsonPage", "NotFoundError", "PaginatedList", diff --git a/avito/core/domain.py b/avito/core/domain.py new file mode 100644 index 0000000..92a8ab1 --- /dev/null +++ b/avito/core/domain.py @@ -0,0 +1,17 @@ +"""Базовый доменный объект SDK.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from avito.core.transport import Transport + + +@dataclass(slots=True, frozen=True) +class DomainObject: + """Базовый доменный объект с доступом к transport-слою.""" + + transport: Transport + + +__all__ = ("DomainObject",) diff --git a/avito/core/exceptions.py b/avito/core/exceptions.py index 8c3501e..e75dbad 100644 --- a/avito/core/exceptions.py +++ b/avito/core/exceptions.py @@ -4,7 +4,6 @@ from collections.abc import Mapping from dataclasses import dataclass, field -from typing import Any _SECRET_KEYS = ( "authorization", @@ -49,7 +48,7 @@ class AvitoError(Exception): status_code: int | None = None error_code: str | None = None operation: str | None = None - metadata: Mapping[str, Any] = field(default_factory=dict) + metadata: Mapping[str, object] = field(default_factory=dict) payload: object | None = None headers: Mapping[str, str] | None = None @@ -87,43 +86,53 @@ class AuthorizationError(AvitoError): class PermissionDeniedError(AuthorizationError): - """Совместимое имя ошибки недостатка прав.""" + """Устаревший псевдоним `AuthorizationError`. Используйте `AuthorizationError` напрямую.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + import warnings + + warnings.warn( + "PermissionDeniedError устарел и будет удалён. Используйте AuthorizationError.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(*args, **kwargs) class ValidationError(AvitoError): - """API отклонил запрос из-за некорректных параметров.""" + """API отклонил запрос из-за некорректных параметров (HTTP 400, 422).""" -class ConfigurationError(ValidationError): - """SDK сконфигурирован некорректно до выполнения HTTP-запроса.""" +class ConfigurationError(AvitoError): + """SDK сконфигурирован некорректно — ошибка обнаружена до выполнения HTTP-запроса.""" class RateLimitError(AvitoError): - """Превышен лимит запросов API.""" + """Превышен лимит запросов API (HTTP 429).""" class ConflictError(AvitoError): - """Операция конфликтует с текущим состоянием upstream-ресурса.""" + """Операция конфликтует с текущим состоянием upstream-ресурса (HTTP 409).""" class UnsupportedOperationError(AvitoError): - """Операция не поддерживается публичным Avito API или данным endpoint.""" + """Операция не поддерживается публичным Avito API или данным endpoint (HTTP 405, 501).""" class UpstreamApiError(AvitoError): """Неизвестная ошибка upstream API вне специализированных типов SDK.""" -class ClientError(UpstreamApiError): - """Совместимое имя прочей клиентской ошибки диапазона `4xx`.""" +class NotFoundError(UpstreamApiError): + """Запрошенный ресурс не найден (HTTP 404).""" -class ServerError(UpstreamApiError): - """Совместимое имя серверной ошибки диапазона `5xx`.""" +class ClientError(UpstreamApiError): + """Прочая клиентская ошибка диапазона 4xx без более конкретного типа.""" -class NotFoundError(UpstreamApiError): - """Запрошенный ресурс не найден.""" +class ServerError(UpstreamApiError): + """Серверная ошибка диапазона 5xx.""" class ResponseMappingError(AvitoError): diff --git a/avito/core/serialization.py b/avito/core/serialization.py index 7ce9c62..52ad1de 100644 --- a/avito/core/serialization.py +++ b/avito/core/serialization.py @@ -5,9 +5,6 @@ from base64 import b64encode from collections.abc import Mapping, Sequence from dataclasses import fields, is_dataclass -from typing import Any - - def _is_public_field(name: str) -> bool: return not name.startswith("_") and name != "raw_payload" @@ -33,7 +30,7 @@ def _serialize_value(value: object) -> object: class SerializableModel: """Mixin для стабильной JSON-compatible сериализации публичных моделей.""" - def to_dict(self) -> dict[str, Any]: + def to_dict(self) -> dict[str, object]: if not is_dataclass(self): raise TypeError("SerializableModel supports dataclass instances only.") return { @@ -42,7 +39,7 @@ def to_dict(self) -> dict[str, Any]: if _is_public_field(field.name) } - def model_dump(self) -> dict[str, Any]: + def model_dump(self) -> dict[str, object]: """Совместимый alias для pydantic-подобного публичного контракта.""" return self.to_dict() diff --git a/avito/core/validation.py b/avito/core/validation.py new file mode 100644 index 0000000..a1be54d --- /dev/null +++ b/avito/core/validation.py @@ -0,0 +1,40 @@ +"""Внутренние валидаторы входных данных доменного слоя.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from avito.core.exceptions import ValidationError + + +def validate_non_empty(name: str, items: Sequence[object]) -> None: + """Проверяет, что последовательность содержит хотя бы один элемент.""" + if not items: + raise ValidationError(f"`{name}` должен содержать хотя бы один элемент.") + + +def validate_positive_int(name: str, value: int) -> None: + """Проверяет, что значение является положительным целым числом.""" + if value <= 0: + raise ValidationError(f"`{name}` должен быть положительным целым числом.") + + +def validate_non_empty_string(name: str, value: str) -> None: + """Проверяет, что строка не является пустой или состоящей из пробелов.""" + if not value.strip(): + raise ValidationError(f"`{name}` не может быть пустой строкой.") + + +def validate_string_items(name: str, values: Sequence[str]) -> None: + """Проверяет, что список строк непустой и каждая строка непустая.""" + validate_non_empty(name, values) + for index, value in enumerate(values): + validate_non_empty_string(f"{name}[{index}]", value) + + +__all__ = ( + "validate_non_empty", + "validate_non_empty_string", + "validate_positive_int", + "validate_string_items", +) diff --git a/avito/cpa/__init__.py b/avito/cpa/__init__.py index 11bf490..0eed32e 100644 --- a/avito/cpa/__init__.py +++ b/avito/cpa/__init__.py @@ -1,6 +1,6 @@ """Пакет cpa.""" -from avito.cpa.domain import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead, DomainObject +from avito.cpa.domain import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead from avito.cpa.models import ( CallTrackingCallInfo, CallTrackingCallResponse, @@ -54,5 +54,4 @@ "CpaPhoneInfo", "CpaPhonesFromChatsRequest", "CpaPhonesResult", - "DomainObject", ) diff --git a/avito/cpa/domain.py b/avito/cpa/domain.py index c44bf76..a81bdeb 100644 --- a/avito/cpa/domain.py +++ b/avito/cpa/domain.py @@ -4,7 +4,8 @@ from dataclasses import dataclass -from avito.core import Transport, ValidationError +from avito.core import ValidationError +from avito.core.domain import DomainObject from avito.cpa.client import ( CallTrackingClient, CpaArchiveClient, @@ -35,13 +36,6 @@ ) -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела cpa.""" - - transport: Transport - - @dataclass(slots=True, frozen=True) class CpaLead(DomainObject): """Доменный объект CPA-лида и связанных lead-операций.""" @@ -163,4 +157,4 @@ def _require_call_id(self) -> str: return str(self.call_id) -__all__ = ("CallTrackingCall", "CpaArchive", "CpaCall", "CpaChat", "CpaLead", "DomainObject") +__all__ = ("CallTrackingCall", "CpaArchive", "CpaCall", "CpaChat", "CpaLead") diff --git a/avito/jobs/__init__.py b/avito/jobs/__init__.py index 749cf18..f5b1e52 100644 --- a/avito/jobs/__init__.py +++ b/avito/jobs/__init__.py @@ -1,6 +1,6 @@ """Пакет jobs.""" -from avito.jobs.domain import Application, DomainObject, JobDictionary, JobWebhook, Resume, Vacancy +from avito.jobs.domain import Application, JobDictionary, JobWebhook, Resume, Vacancy from avito.jobs.models import ( ApplicationActionRequest, ApplicationIdsQuery, @@ -42,7 +42,6 @@ "ApplicationStatesResult", "ApplicationViewedItem", "ApplicationViewedRequest", - "DomainObject", "JobActionResult", "JobDictionariesResult", "JobDictionary", diff --git a/avito/jobs/domain.py b/avito/jobs/domain.py index 271c834..250d5b9 100644 --- a/avito/jobs/domain.py +++ b/avito/jobs/domain.py @@ -4,7 +4,8 @@ from dataclasses import dataclass -from avito.core import Transport, ValidationError +from avito.core import ValidationError +from avito.core.domain import DomainObject from avito.jobs.client import ( ApplicationsClient, DictionariesClient, @@ -43,13 +44,6 @@ ) -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела jobs.""" - - transport: Transport - - @dataclass(slots=True, frozen=True) class Vacancy(DomainObject): """Доменный объект вакансий.""" @@ -222,4 +216,4 @@ def _require_dictionary_id(self) -> str: return str(self.dictionary_id) -__all__ = ("Application", "DomainObject", "JobDictionary", "JobWebhook", "Resume", "Vacancy") +__all__ = ("Application", "JobDictionary", "JobWebhook", "Resume", "Vacancy") diff --git a/avito/messenger/__init__.py b/avito/messenger/__init__.py index 6b22864..29c7422 100644 --- a/avito/messenger/__init__.py +++ b/avito/messenger/__init__.py @@ -5,7 +5,6 @@ ChatMedia, ChatMessage, ChatWebhook, - DomainObject, SpecialOfferCampaign, ) from avito.messenger.models import ( @@ -33,7 +32,6 @@ "ChatMessage", "ChatWebhook", "ChatsResult", - "DomainObject", "MessageActionResult", "MessageInfo", "MessagesResult", diff --git a/avito/messenger/domain.py b/avito/messenger/domain.py index 30b6f62..80211ae 100644 --- a/avito/messenger/domain.py +++ b/avito/messenger/domain.py @@ -4,7 +4,8 @@ from dataclasses import dataclass -from avito.core import Transport, ValidationError +from avito.core import ValidationError +from avito.core.domain import DomainObject from avito.messenger.client import MediaClient, MessengerClient, SpecialOffersClient, WebhookClient from avito.messenger.models import ( BlacklistRequest, @@ -33,13 +34,6 @@ ) -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела messenger.""" - - transport: Transport - - @dataclass(slots=True, frozen=True) class Chat(DomainObject): """Доменный объект чата.""" @@ -255,6 +249,5 @@ def _require_campaign_id(self) -> str: "ChatMedia", "ChatMessage", "ChatWebhook", - "DomainObject", "SpecialOfferCampaign", ) diff --git a/avito/messenger/mappers.py b/avito/messenger/mappers.py index d95c421..fb3a4a4 100644 --- a/avito/messenger/mappers.py +++ b/avito/messenger/mappers.py @@ -86,7 +86,7 @@ def map_chat(payload: object) -> ChatInfo: last_message = data.get("last_message") last_message_data = cast(Payload, last_message) if isinstance(last_message, Mapping) else {} return ChatInfo( - id=_str(data, "id", "chat_id", "chatId"), + chat_id=_str(data, "id", "chat_id", "chatId"), user_id=_int(data, "user_id", "userId"), title=_str(data, "title", "name"), unread_count=_int(data, "unread_count", "unreadCount"), @@ -109,7 +109,7 @@ def map_message(payload: object) -> MessageInfo: data = _expect_mapping(payload) return MessageInfo( - id=_str(data, "id", "message_id", "messageId"), + message_id=_str(data, "id", "message_id", "messageId"), chat_id=_str(data, "chat_id", "chatId"), author_id=_int(data, "author_id", "authorId", "user_id", "userId"), text=_str(data, "text", "message"), diff --git a/avito/messenger/models.py b/avito/messenger/models.py index 338311d..eac952e 100644 --- a/avito/messenger/models.py +++ b/avito/messenger/models.py @@ -12,7 +12,7 @@ class ChatInfo(SerializableModel): """Информация о чате.""" - id: str | None + chat_id: str | None user_id: int | None title: str | None unread_count: int | None @@ -65,7 +65,7 @@ def to_payload(self) -> dict[str, object]: class MessageInfo(SerializableModel): """Информация о сообщении чата.""" - id: str | None + message_id: str | None chat_id: str | None author_id: int | None text: str | None diff --git a/avito/orders/__init__.py b/avito/orders/__init__.py index 4f63411..e1a304e 100644 --- a/avito/orders/__init__.py +++ b/avito/orders/__init__.py @@ -3,7 +3,6 @@ from avito.orders.domain import ( DeliveryOrder, DeliveryTask, - DomainObject, Order, OrderLabel, SandboxDelivery, @@ -101,7 +100,6 @@ "DeliveryTaskInfo", "DeliveryDirection", "DeliveryDirectionZone", - "DomainObject", "LabelPdfResult", "LabelTaskResult", "Order", diff --git a/avito/orders/domain.py b/avito/orders/domain.py index 9edb7f2..cfec416 100644 --- a/avito/orders/domain.py +++ b/avito/orders/domain.py @@ -4,7 +4,8 @@ from dataclasses import dataclass -from avito.core import Transport, ValidationError +from avito.core import ValidationError +from avito.core.domain import DomainObject from avito.orders.client import ( DeliveryClient, DeliveryTasksClient, @@ -62,13 +63,6 @@ ) -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела orders.""" - - transport: Transport - - @dataclass(slots=True, frozen=True) class Order(DomainObject): """Доменный объект заказа.""" @@ -298,7 +292,6 @@ def update(self, *, request: StockUpdateRequest) -> StockUpdateResult: __all__ = ( "DeliveryOrder", "DeliveryTask", - "DomainObject", "Order", "OrderLabel", "SandboxDelivery", diff --git a/avito/promotion/__init__.py b/avito/promotion/__init__.py index a6bd7dc..e820d2e 100644 --- a/avito/promotion/__init__.py +++ b/avito/promotion/__init__.py @@ -4,7 +4,6 @@ AutostrategyCampaign, BbipPromotion, CpaAuction, - DomainObject, PromotionOrder, TargetActionPricing, TrxPromotion, @@ -17,9 +16,8 @@ BbipBudgetOption, BbipDurationRange, BbipForecast, - BbipForecastRequestItem, BbipForecastsResult, - BbipOrderItem, + BbipItem, BbipSuggest, BbipSuggestsResult, CampaignActionResult, @@ -54,7 +52,7 @@ TargetActionPromotion, TargetActionPromotionsByItemIdsResult, TrxCommissionsResult, - TrxPromotionApplyItem, + TrxItem, ) __all__ = ( @@ -66,9 +64,8 @@ "BbipBudgetOption", "BbipDurationRange", "BbipForecast", - "BbipForecastRequestItem", "BbipForecastsResult", - "BbipOrderItem", + "BbipItem", "BbipPromotion", "BbipSuggest", "BbipSuggestsResult", @@ -85,7 +82,6 @@ "CpaAuction", "CpaAuctionBidsResult", "CreateItemBid", - "DomainObject", "PromotionActionResult", "PromotionOrder", "PromotionOrderError", @@ -108,6 +104,6 @@ "TargetActionPromotion", "TargetActionPromotionsByItemIdsResult", "TrxCommissionsResult", + "TrxItem", "TrxPromotion", - "TrxPromotionApplyItem", ) diff --git a/avito/promotion/domain.py b/avito/promotion/domain.py index 35e5240..28975bb 100644 --- a/avito/promotion/domain.py +++ b/avito/promotion/domain.py @@ -2,10 +2,16 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Mapping from dataclasses import dataclass -from avito.core import Transport, ValidationError +from avito.core import ValidationError +from avito.core.domain import DomainObject +from avito.core.validation import ( + validate_non_empty, + validate_non_empty_string, + validate_positive_int, +) from avito.promotion.client import ( AutostrategyClient, BbipClient, @@ -17,9 +23,8 @@ from avito.promotion.models import ( AutostrategyBudget, AutostrategyStat, - BbipForecastRequestItem, BbipForecastsResult, - BbipOrderItem, + BbipItem, BbipSuggestsResult, CampaignActionResult, CampaignDetailsResult, @@ -53,34 +58,13 @@ TargetActionGetBidsResult, TargetActionPromotionsByItemIdsResult, TrxCommissionsResult, - TrxPromotionApplyItem, + TrxItem, UpdateAutoBidRequest, UpdateAutostrategyCampaignRequest, UpdateManualBidRequest, ) -def _validate_non_empty_items(name: str, items: Sequence[object]) -> None: - if not items: - raise ValidationError(f"`{name}` должен содержать хотя бы один элемент.") - - -def _validate_positive_int(name: str, value: int) -> None: - if value <= 0: - raise ValidationError(f"`{name}` должен быть положительным целым числом.") - - -def _validate_non_empty_string(name: str, value: str) -> None: - if not value.strip(): - raise ValidationError(f"`{name}` не может быть пустой строкой.") - - -def _validate_string_items(name: str, values: Sequence[str]) -> None: - _validate_non_empty_items(name, values) - for index, value in enumerate(values): - _validate_non_empty_string(f"{name}[{index}]", value) - - def _preview_result( *, action: str, @@ -97,13 +81,6 @@ def _preview_result( ) -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела promotion.""" - - transport: Transport - - @dataclass(slots=True, frozen=True) class PromotionOrder(DomainObject): """Доменный объект заявок и словарей promotion API.""" @@ -155,7 +132,7 @@ class BbipPromotion(DomainObject): item_id: int | str | None = None user_id: int | str | None = None - def get_forecasts(self, *, items: list[BbipForecastRequestItem]) -> BbipForecastsResult: + def get_forecasts(self, *, items: list[BbipItem]) -> BbipForecastsResult: """Получает прогнозы BBIP.""" return BbipClient(self.transport).get_forecasts(CreateBbipForecastsRequest(items=items)) @@ -163,17 +140,17 @@ def get_forecasts(self, *, items: list[BbipForecastRequestItem]) -> BbipForecast def create_order( self, *, - items: list[BbipOrderItem], + items: list[BbipItem], dry_run: bool = False, ) -> PromotionActionResult: """Подключает BBIP-продвижение.""" - _validate_non_empty_items("items", items) + 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) + 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) request = CreateBbipOrderRequest(items=items) request_payload = request.to_payload() target = {"item_ids": [item.item_id for item in items]} @@ -209,18 +186,18 @@ class TrxPromotion(DomainObject): def apply( self, *, - items: list[TrxPromotionApplyItem], + items: list[TrxItem], dry_run: bool = False, ) -> PromotionActionResult: """Запускает TrxPromo.""" - _validate_non_empty_items("items", items) + 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) - _validate_non_empty_string(f"items[{index}].date_from", item.date_from) + validate_positive_int(f"items[{index}].item_id", item.item_id) + validate_positive_int(f"items[{index}].commission", item.commission) + validate_non_empty_string(f"items[{index}].date_from", item.date_from) if item.date_to is not None: - _validate_non_empty_string(f"items[{index}].date_to", item.date_to) + validate_non_empty_string(f"items[{index}].date_to", item.date_to) request = CreateTrxPromotionApplyRequest(items=items) request_payload = request.to_payload() target = {"item_ids": [item.item_id for item in items]} @@ -237,7 +214,7 @@ def delete( """Останавливает TrxPromo.""" resolved_item_ids = item_ids or self._resource_item_ids() - _validate_non_empty_items("item_ids", resolved_item_ids) + validate_non_empty("item_ids", resolved_item_ids) request = CancelTrxPromotionRequest(item_ids=resolved_item_ids) request_payload = request.to_payload() target = {"item_ids": list(resolved_item_ids)} @@ -317,7 +294,7 @@ def delete( """Останавливает продвижение.""" resolved_item_id = item_id or self._require_item_id() - _validate_positive_int("item_id", resolved_item_id) + validate_positive_int("item_id", resolved_item_id) request = DeletePromotionRequest(item_id=resolved_item_id) request_payload = request.to_payload() target = {"item_id": resolved_item_id} @@ -337,10 +314,10 @@ def update_auto( """Применяет автоматическую настройку.""" 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) + 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 = UpdateAutoBidRequest( item_id=resolved_item_id, action_type_id=action_type_id, @@ -369,11 +346,11 @@ def update_manual( """Применяет ручную настройку.""" 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) + 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) + validate_positive_int("limit_penny", limit_penny) request = UpdateManualBidRequest( item_id=resolved_item_id, action_type_id=action_type_id, @@ -539,7 +516,6 @@ def _require_campaign_id(self) -> int: "AutostrategyCampaign", "BbipPromotion", "CpaAuction", - "DomainObject", "PromotionOrder", "TargetActionPricing", "TrxPromotion", diff --git a/avito/promotion/models.py b/avito/promotion/models.py index 0bc4361..5d556ec 100644 --- a/avito/promotion/models.py +++ b/avito/promotion/models.py @@ -132,35 +132,35 @@ class PromotionOrderStatusResult(SerializableModel): @dataclass(slots=True, frozen=True) -class BbipForecastRequestItem: - """Параметры прогноза BBIP по объявлению.""" +class BbipItem(SerializableModel): + """Параметры BBIP по объявлению (прогноз или заявка).""" item_id: int duration: int price: int old_price: int - def to_payload(self) -> dict[str, object]: - """Сериализует один BBIP-элемент прогноза.""" - - return { - "itemId": self.item_id, - "duration": self.duration, - "price": self.price, - "oldPrice": self.old_price, - } - @dataclass(slots=True, frozen=True) class CreateBbipForecastsRequest: """Запрос прогноза BBIP.""" - items: list[BbipForecastRequestItem] + items: list[BbipItem] def to_payload(self) -> dict[str, object]: """Сериализует запрос прогноза BBIP.""" - return {"items": [item.to_payload() for item in self.items]} + return { + "items": [ + { + "itemId": item.item_id, + "duration": item.duration, + "price": item.price, + "oldPrice": item.old_price, + } + for item in self.items + ] + } @dataclass(slots=True, frozen=True) @@ -181,36 +181,26 @@ class BbipForecastsResult(SerializableModel): items: list[BbipForecast] -@dataclass(slots=True, frozen=True) -class BbipOrderItem: - """Параметры подключения BBIP по объявлению.""" - - item_id: int - duration: int - price: int - old_price: int - - def to_payload(self) -> dict[str, object]: - """Сериализует одну заявку BBIP.""" - - return { - "itemId": self.item_id, - "duration": self.duration, - "price": self.price, - "oldPrice": self.old_price, - } - - @dataclass(slots=True, frozen=True) class CreateBbipOrderRequest: """Запрос подключения BBIP.""" - items: list[BbipOrderItem] + items: list[BbipItem] def to_payload(self) -> dict[str, object]: """Сериализует запрос подключения BBIP.""" - return {"items": [item.to_payload() for item in self.items]} + return { + "items": [ + { + "itemId": item.item_id, + "duration": item.duration, + "price": item.price, + "oldPrice": item.old_price, + } + for item in self.items + ] + } @dataclass(slots=True, frozen=True) @@ -285,37 +275,35 @@ class BbipSuggestsResult(SerializableModel): @dataclass(slots=True, frozen=True) -class TrxPromotionApplyItem: - """Параметры запуска TrxPromo по объявлению.""" +class TrxItem(SerializableModel): + """Параметры TrxPromo по объявлению.""" item_id: int commission: int date_from: str date_to: str | None = None - def to_payload(self) -> dict[str, object]: - """Сериализует один элемент запуска TrxPromo.""" - - payload: dict[str, object] = { - "itemID": self.item_id, - "commission": self.commission, - "dateFrom": self.date_from, - } - if self.date_to is not None: - payload["dateTo"] = self.date_to - return payload - @dataclass(slots=True, frozen=True) class CreateTrxPromotionApplyRequest: """Запрос запуска TrxPromo.""" - items: list[TrxPromotionApplyItem] + items: list[TrxItem] def to_payload(self) -> dict[str, object]: """Сериализует запрос запуска TrxPromo.""" - return {"items": [item.to_payload() for item in self.items]} + items_payload: list[dict[str, object]] = [] + for item in self.items: + entry: dict[str, object] = { + "itemID": item.item_id, + "commission": item.commission, + "dateFrom": item.date_from, + } + if item.date_to is not None: + entry["dateTo"] = item.date_to + items_payload.append(entry) + return {"items": items_payload} @dataclass(slots=True, frozen=True) diff --git a/avito/ratings/__init__.py b/avito/ratings/__init__.py index 6ece590..d389823 100644 --- a/avito/ratings/__init__.py +++ b/avito/ratings/__init__.py @@ -1,10 +1,9 @@ """Пакет ratings.""" -from avito.ratings.domain import DomainObject, RatingProfile, Review, ReviewAnswer +from avito.ratings.domain import RatingProfile, Review, ReviewAnswer from avito.ratings.models import RatingProfileInfo, ReviewAnswerInfo, ReviewInfo, ReviewsResult __all__ = ( - "DomainObject", "RatingProfile", "RatingProfileInfo", "Review", diff --git a/avito/ratings/domain.py b/avito/ratings/domain.py index 792fad1..71c784c 100644 --- a/avito/ratings/domain.py +++ b/avito/ratings/domain.py @@ -4,7 +4,8 @@ from dataclasses import dataclass -from avito.core import Transport, ValidationError +from avito.core import ValidationError +from avito.core.domain import DomainObject from avito.ratings.client import RatingsClient from avito.ratings.models import ( CreateReviewAnswerRequest, @@ -15,13 +16,6 @@ ) -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела ratings.""" - - transport: Transport - - @dataclass(slots=True, frozen=True) class Review(DomainObject): """Доменный объект отзывов.""" @@ -65,4 +59,4 @@ def get(self) -> RatingProfileInfo: return RatingsClient(self.transport).get_ratings_info() -__all__ = ("DomainObject", "RatingProfile", "Review", "ReviewAnswer") +__all__ = ("RatingProfile", "Review", "ReviewAnswer") diff --git a/avito/realty/__init__.py b/avito/realty/__init__.py index 652d149..65d9ad7 100644 --- a/avito/realty/__init__.py +++ b/avito/realty/__init__.py @@ -1,7 +1,6 @@ """Пакет realty.""" from avito.realty.domain import ( - DomainObject, RealtyAnalyticsReport, RealtyBooking, RealtyListing, @@ -23,7 +22,6 @@ ) __all__ = ( - "DomainObject", "RealtyActionResult", "RealtyAnalyticsInfo", "RealtyAnalyticsReport", diff --git a/avito/realty/domain.py b/avito/realty/domain.py index a0e40fb..2487acd 100644 --- a/avito/realty/domain.py +++ b/avito/realty/domain.py @@ -4,7 +4,8 @@ from dataclasses import dataclass -from avito.core import Transport, ValidationError +from avito.core import ValidationError +from avito.core.domain import DomainObject from avito.realty.client import RealtyAnalyticsClient, ShortTermRentClient from avito.realty.models import ( RealtyActionResult, @@ -19,13 +20,6 @@ ) -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела realty.""" - - transport: Transport - - @dataclass(slots=True, frozen=True) class RealtyListing(DomainObject): """Доменный объект объявления краткосрочной аренды.""" @@ -161,7 +155,6 @@ def _require_item_id(self) -> str: __all__ = ( - "DomainObject", "RealtyAnalyticsReport", "RealtyBooking", "RealtyListing", diff --git a/avito/settings.py b/avito/settings.py index cdc2da3..c6f2fb2 100644 --- a/avito/settings.py +++ b/avito/settings.py @@ -1,4 +1,12 @@ -"""Совместимые импорты конфигурации SDK.""" +"""Устаревший модуль совместимости — импортируйте из `avito.config` напрямую.""" + +import warnings + +warnings.warn( + "avito.settings устарел и будет удалён. Используйте `avito.config.AvitoSettings` напрямую.", + DeprecationWarning, + stacklevel=2, +) from avito.config import AvitoSettings from avito.core.retries import RetryPolicy diff --git a/avito/tariffs/__init__.py b/avito/tariffs/__init__.py index e756ddc..cee4ad9 100644 --- a/avito/tariffs/__init__.py +++ b/avito/tariffs/__init__.py @@ -1,6 +1,6 @@ """Пакет tariffs.""" -from avito.tariffs.domain import DomainObject, Tariff +from avito.tariffs.domain import Tariff from avito.tariffs.models import TariffContractInfo, TariffInfo -__all__ = ("DomainObject", "Tariff", "TariffContractInfo", "TariffInfo") +__all__ = ("Tariff", "TariffContractInfo", "TariffInfo") diff --git a/avito/tariffs/domain.py b/avito/tariffs/domain.py index db2b31f..837ffd0 100644 --- a/avito/tariffs/domain.py +++ b/avito/tariffs/domain.py @@ -4,18 +4,11 @@ from dataclasses import dataclass -from avito.core import Transport +from avito.core.domain import DomainObject from avito.tariffs.client import TariffsClient from avito.tariffs.models import TariffInfo -@dataclass(slots=True, frozen=True) -class DomainObject: - """Базовый доменный объект раздела tariffs.""" - - transport: Transport - - @dataclass(slots=True, frozen=True) class Tariff(DomainObject): """Доменный объект тарифа.""" @@ -27,4 +20,4 @@ def get_tariff_info(self) -> TariffInfo: return TariffsClient(self.transport).get_tariff_info() -__all__ = ("DomainObject", "Tariff") +__all__ = ("Tariff",) From 113a1bd7f2809e3c2f2c84364fa3d9e08791a405 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Mon, 20 Apr 2026 23:12:24 +0300 Subject: [PATCH 10/17] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D0=B3=D0=B0=D0=B9=D0=B4=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- avito/promotion/domain.py | 70 +++++++++++++++++++++++--------- avito/promotion/mappers.py | 24 ++++++++--- avito/promotion/models.py | 82 ++++++++++++++++++++++++++------------ 3 files changed, 125 insertions(+), 51 deletions(-) diff --git a/avito/promotion/domain.py b/avito/promotion/domain.py index 28975bb..c7f7d75 100644 --- a/avito/promotion/domain.py +++ b/avito/promotion/domain.py @@ -4,6 +4,7 @@ from collections.abc import Mapping from dataclasses import dataclass +from datetime import datetime from avito.core import ValidationError from avito.core.domain import DomainObject @@ -25,12 +26,15 @@ AutostrategyStat, BbipForecastsResult, BbipItem, + BbipItemInput, BbipSuggestsResult, + BidItemInput, CampaignActionResult, CampaignDetailsResult, CampaignListFilter, CampaignOrderBy, CampaignsResult, + CampaignUpdateTimeFilter, CancelTrxPromotionRequest, CpaAuctionBidsResult, CreateAutostrategyBudgetRequest, @@ -59,6 +63,7 @@ TargetActionPromotionsByItemIdsResult, TrxCommissionsResult, TrxItem, + TrxItemInput, UpdateAutoBidRequest, UpdateAutostrategyCampaignRequest, UpdateManualBidRequest, @@ -132,28 +137,46 @@ class BbipPromotion(DomainObject): item_id: int | str | None = None user_id: int | str | None = None - def get_forecasts(self, *, items: list[BbipItem]) -> BbipForecastsResult: + def get_forecasts(self, *, items: list[BbipItemInput]) -> BbipForecastsResult: """Получает прогнозы BBIP.""" - return BbipClient(self.transport).get_forecasts(CreateBbipForecastsRequest(items=items)) + bbip_items = [ + BbipItem( + item_id=item["item_id"], + duration=item["duration"], + price=item["price"], + old_price=item["old_price"], + ) + for item in items + ] + return BbipClient(self.transport).get_forecasts(CreateBbipForecastsRequest(items=bbip_items)) def create_order( self, *, - items: list[BbipItem], + items: list[BbipItemInput], dry_run: bool = False, ) -> PromotionActionResult: """Подключает BBIP-продвижение.""" 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) - request = CreateBbipOrderRequest(items=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 = [ + BbipItem( + item_id=item["item_id"], + duration=item["duration"], + price=item["price"], + old_price=item["old_price"], + ) + for item in items + ] + request = CreateBbipOrderRequest(items=bbip_items) request_payload = request.to_payload() - target = {"item_ids": [item.item_id for item in items]} + target: dict[str, object] = {"item_ids": [item["item_id"] for item in items]} if dry_run: return _preview_result( action="create_order", @@ -186,21 +209,29 @@ class TrxPromotion(DomainObject): def apply( self, *, - items: list[TrxItem], + items: list[TrxItemInput], dry_run: bool = False, ) -> PromotionActionResult: """Запускает TrxPromo.""" 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) - validate_non_empty_string(f"items[{index}].date_from", item.date_from) - if item.date_to is not None: - validate_non_empty_string(f"items[{index}].date_to", item.date_to) - request = CreateTrxPromotionApplyRequest(items=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.get("date_from"), datetime): + raise ValidationError(f"items[{index}].date_from должен быть datetime.") + trx_items = [ + TrxItem( + item_id=item["item_id"], + commission=item["commission"], + date_from=item["date_from"], + date_to=item.get("date_to"), + ) + for item in items + ] + request = CreateTrxPromotionApplyRequest(items=trx_items) request_payload = request.to_payload() - target = {"item_ids": [item.item_id for item in items]} + 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) return TrxPromoClient(self.transport).apply(request) @@ -255,10 +286,11 @@ def get_user_bids( batch_size=batch_size, ) - def create_item_bids(self, *, items: list[CreateItemBid]) -> PromotionActionResult: + def create_item_bids(self, *, items: list[BidItemInput]) -> PromotionActionResult: """Сохраняет новые ставки по объявлениям.""" - return CpaAuctionClient(self.transport).create_item_bids(CreateItemBidsRequest(items=items)) + bids = [CreateItemBid(item_id=item["item_id"], price_penny=item["price_penny"]) for item in items] + return CpaAuctionClient(self.transport).create_item_bids(CreateItemBidsRequest(items=bids)) @dataclass(slots=True, frozen=True) diff --git a/avito/promotion/mappers.py b/avito/promotion/mappers.py index 68418a8..8b98463 100644 --- a/avito/promotion/mappers.py +++ b/avito/promotion/mappers.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from datetime import datetime from typing import cast from avito.core.exceptions import ResponseMappingError @@ -105,6 +106,17 @@ def _bool(payload: Payload, *keys: str) -> bool | None: return None +def _datetime(payload: Payload, *keys: str) -> datetime | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str): + try: + return datetime.fromisoformat(value) + except ValueError: + return None + return None + + def _items_payload(payload: Payload) -> list[Payload]: return _list(payload, "items", "result", "services", "orders", "campaigns") @@ -153,7 +165,7 @@ def map_promotion_orders(payload: object) -> PromotionOrdersResult: item_id=_int(item, "itemId", "itemID"), service_code=_str(item, "serviceCode", "code"), status=_str(item, "status"), - created_at=_str(item, "createdAt", "created_at"), + created_at=_datetime(item, "createdAt", "created_at"), ) for item in _items_payload(data) ], @@ -575,14 +587,14 @@ def _map_campaign(payload: Payload) -> CampaignInfo | None: campaign_type=_str(payload, "campaignType"), budget=_int(payload, "budget"), balance=_int(payload, "balance"), - create_time=_str(payload, "createTime"), + create_time=_datetime(payload, "createTime"), description=_str(payload, "description"), - finish_time=_str(payload, "finishTime"), + finish_time=_datetime(payload, "finishTime"), items_count=_int(payload, "itemsCount"), - start_time=_str(payload, "startTime"), + start_time=_datetime(payload, "startTime"), status_id=_int(payload, "statusId"), title=_str(payload, "title"), - update_time=_str(payload, "updateTime"), + update_time=_datetime(payload, "updateTime"), user_id=_int(payload, "userId"), version=_int(payload, "version"), ) @@ -656,7 +668,7 @@ def map_autostrategy_stat(payload: object) -> AutostrategyStat: def _map_autostrategy_stat_item(payload: Payload) -> AutostrategyStatItem: return AutostrategyStatItem( - date=_str(payload, "date"), + date=_datetime(payload, "date"), calls=_int(payload, "calls"), views=_int(payload, "views"), calls_forecast=_map_campaign_forecast_range(_mapping(payload, "callsForecast")), diff --git a/avito/promotion/models.py b/avito/promotion/models.py index 5d556ec..59bc9ec 100644 --- a/avito/promotion/models.py +++ b/avito/promotion/models.py @@ -3,6 +3,8 @@ from __future__ import annotations from dataclasses import dataclass, field +from datetime import datetime +from typing import TypeAlias, TypedDict from avito.core.serialization import SerializableModel @@ -78,7 +80,7 @@ class PromotionOrderInfo(SerializableModel): item_id: int | None service_code: str | None status: str | None - created_at: str | None + created_at: datetime | None @dataclass(slots=True, frozen=True) @@ -131,6 +133,31 @@ class PromotionOrderStatusResult(SerializableModel): errors: list[PromotionOrderError] +class BbipItemInput(TypedDict): + """Входные параметры одного объявления для BBIP-методов.""" + + item_id: int + duration: int + price: int + old_price: int + + +class TrxItemInput(TypedDict, total=False): + """Входные параметры одного объявления для TrxPromo-методов.""" + + item_id: int + commission: int + date_from: datetime + date_to: datetime | None + + +class BidItemInput(TypedDict): + """Входные параметры одной ставки CPA-аукциона.""" + + item_id: int + price_penny: int + + @dataclass(slots=True, frozen=True) class BbipItem(SerializableModel): """Параметры BBIP по объявлению (прогноз или заявка).""" @@ -181,6 +208,9 @@ class BbipForecastsResult(SerializableModel): items: list[BbipForecast] +PromotionForecast: TypeAlias = BbipForecast + + @dataclass(slots=True, frozen=True) class CreateBbipOrderRequest: """Запрос подключения BBIP.""" @@ -280,8 +310,8 @@ class TrxItem(SerializableModel): item_id: int commission: int - date_from: str - date_to: str | None = None + date_from: datetime + date_to: datetime | None = None @dataclass(slots=True, frozen=True) @@ -298,10 +328,10 @@ def to_payload(self) -> dict[str, object]: entry: dict[str, object] = { "itemID": item.item_id, "commission": item.commission, - "dateFrom": item.date_from, + "dateFrom": item.date_from.isoformat(), } if item.date_to is not None: - entry["dateTo"] = item.date_to + entry["dateTo"] = item.date_to.isoformat() items_payload.append(entry) return {"items": items_payload} @@ -592,8 +622,8 @@ class CreateAutostrategyBudgetRequest: """Запрос расчета бюджета кампании.""" campaign_type: str - start_time: str | None = None - finish_time: str | None = None + start_time: datetime | None = None + finish_time: datetime | None = None items: list[int] | None = None def to_payload(self) -> dict[str, object]: @@ -601,9 +631,9 @@ def to_payload(self) -> dict[str, object]: payload: dict[str, object] = {"campaignType": self.campaign_type} if self.start_time is not None: - payload["startTime"] = self.start_time + payload["startTime"] = self.start_time.isoformat() if self.finish_time is not None: - payload["finishTime"] = self.finish_time + payload["finishTime"] = self.finish_time.isoformat() if self.items is not None: payload["items"] = list(self.items) return payload @@ -624,14 +654,14 @@ class CampaignInfo(SerializableModel): campaign_type: str | None budget: int | None balance: int | None - create_time: str | None + create_time: datetime | None description: str | None - finish_time: str | None + finish_time: datetime | None items_count: int | None - start_time: str | None + start_time: datetime | None status_id: int | None title: str | None - update_time: str | None + update_time: datetime | None user_id: int | None version: int | None @@ -696,9 +726,9 @@ class CreateAutostrategyCampaignRequest: budget_real: int | None = None calc_id: int | None = None description: str | None = None - finish_time: str | None = None + finish_time: datetime | None = None items: list[int] | None = None - start_time: str | None = None + start_time: datetime | None = None def to_payload(self) -> dict[str, object]: """Сериализует запрос создания кампании.""" @@ -718,11 +748,11 @@ def to_payload(self) -> dict[str, object]: if self.description is not None: payload["description"] = self.description if self.finish_time is not None: - payload["finishTime"] = self.finish_time + payload["finishTime"] = self.finish_time.isoformat() if self.items is not None: payload["items"] = list(self.items) if self.start_time is not None: - payload["startTime"] = self.start_time + payload["startTime"] = self.start_time.isoformat() return payload @@ -735,9 +765,9 @@ class UpdateAutostrategyCampaignRequest: budget: int | None = None calc_id: int | None = None description: str | None = None - finish_time: str | None = None + finish_time: datetime | None = None items: list[int] | None = None - start_time: str | None = None + start_time: datetime | None = None title: str | None = None def to_payload(self) -> dict[str, object]: @@ -754,11 +784,11 @@ def to_payload(self) -> dict[str, object]: if self.description is not None: payload["description"] = self.description if self.finish_time is not None: - payload["finishTime"] = self.finish_time + payload["finishTime"] = self.finish_time.isoformat() if self.items is not None: payload["items"] = list(self.items) if self.start_time is not None: - payload["startTime"] = self.start_time + payload["startTime"] = self.start_time.isoformat() if self.title is not None: payload["title"] = self.title return payload @@ -793,15 +823,15 @@ def to_payload(self) -> dict[str, object]: class CampaignUpdateTimeFilter: """Фильтр кампаний по времени обновления.""" - from_time: str | None = None - to_time: str | None = None + from_time: datetime | None = None + to_time: datetime | None = None def to_payload(self) -> dict[str, object]: payload: dict[str, object] = {} if self.from_time is not None: - payload["from"] = self.from_time + payload["from"] = self.from_time.isoformat() if self.to_time is not None: - payload["to"] = self.to_time + payload["to"] = self.to_time.isoformat() return payload @@ -870,7 +900,7 @@ def to_payload(self) -> dict[str, object]: class AutostrategyStatItem(SerializableModel): """Статистика кампании за день.""" - date: str | None + date: datetime | None calls: int | None views: int | None calls_forecast: CampaignForecastRange | None = None From bf1c5f4760a91daae8536171f33a2dceceb2a866 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Mon, 20 Apr 2026 23:21:55 +0300 Subject: [PATCH 11/17] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D0=B3=D0=B0=D0=B9=D0=B4=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +-- avito/ads/domain.py | 2 - avito/core/exceptions.py | 2 +- avito/core/serialization.py | 2 + avito/promotion/domain.py | 2 - avito/promotion/models.py | 22 ++++++- avito/tariffs/domain.py | 3 +- tests/test_config.py | 16 ++--- todo.md | 126 ++++++++++++++++++++++++++++++++++++ 9 files changed, 159 insertions(+), 24 deletions(-) create mode 100644 todo.md diff --git a/README.md b/README.md index e17138b..b27ef0e 100644 --- a/README.md +++ b/README.md @@ -71,10 +71,10 @@ client = AvitoClient.from_env() Поддерживаемые env-переменные и alias-имена: -- `AVITO_BASE_URL`, alias: `BASE_URL` -- `AVITO_USER_ID`, alias: `USER_ID` -- `AVITO_AUTH__CLIENT_ID`, alias: `AVITO_CLIENT_ID`, `CLIENT_ID` -- `AVITO_AUTH__CLIENT_SECRET`, alias: `AVITO_CLIENT_SECRET`, `AVITO_SECRET`, `CLIENT_SECRET`, `SECRET` +- `AVITO_BASE_URL` +- `AVITO_USER_ID` +- `AVITO_AUTH__CLIENT_ID`, alias: `AVITO_CLIENT_ID` +- `AVITO_AUTH__CLIENT_SECRET`, alias: `AVITO_CLIENT_SECRET` - `AVITO_AUTH__REFRESH_TOKEN`, alias: `AVITO_REFRESH_TOKEN`, `REFRESH_TOKEN` - `AVITO_AUTH__SCOPE`, alias: `AVITO_SCOPE`, `SCOPE` - `AVITO_AUTH__TOKEN_URL`, alias: `AVITO_TOKEN_URL`, `TOKEN_URL` diff --git a/avito/ads/domain.py b/avito/ads/domain.py index de4202e..0911bf1 100644 --- a/avito/ads/domain.py +++ b/avito/ads/domain.py @@ -366,7 +366,6 @@ class AutoloadReport(DomainObject): """Доменный объект отчета автозагрузки.""" report_id: int | str | None = None - user_id: int | str | None = None def get(self) -> AutoloadReportDetails: """Получает конкретный отчет v3.""" @@ -424,7 +423,6 @@ class AutoloadArchive(DomainObject): """Доменный объект архивных операций автозагрузки.""" report_id: int | str | None = None - user_id: int | str | None = None def get_profile(self) -> AutoloadProfileSettings: """Получает архивный профиль автозагрузки.""" diff --git a/avito/core/exceptions.py b/avito/core/exceptions.py index e75dbad..4162819 100644 --- a/avito/core/exceptions.py +++ b/avito/core/exceptions.py @@ -40,7 +40,7 @@ def sanitize_metadata(value: object) -> object: return value -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class AvitoError(Exception): """Базовое исключение SDK с безопасными диагностическими метаданными.""" diff --git a/avito/core/serialization.py b/avito/core/serialization.py index 52ad1de..c4a54c9 100644 --- a/avito/core/serialization.py +++ b/avito/core/serialization.py @@ -5,6 +5,8 @@ from base64 import b64encode from collections.abc import Mapping, Sequence from dataclasses import fields, is_dataclass + + def _is_public_field(name: str) -> bool: return not name.startswith("_") and name != "raw_payload" diff --git a/avito/promotion/domain.py b/avito/promotion/domain.py index c7f7d75..6a5a6da 100644 --- a/avito/promotion/domain.py +++ b/avito/promotion/domain.py @@ -91,7 +91,6 @@ class PromotionOrder(DomainObject): """Доменный объект заявок и словарей promotion API.""" order_id: int | str | None = None - user_id: int | str | None = None def get_service_dictionary(self) -> PromotionServiceDictionary: """Получает словарь услуг продвижения.""" @@ -271,7 +270,6 @@ class CpaAuction(DomainObject): """Доменный объект CPA-аукциона.""" item_id: int | str | None = None - user_id: int | str | None = None def get_user_bids( self, diff --git a/avito/promotion/models.py b/avito/promotion/models.py index 59bc9ec..eccdad9 100644 --- a/avito/promotion/models.py +++ b/avito/promotion/models.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field from datetime import datetime from typing import TypeAlias, TypedDict +import warnings from avito.core.serialization import SerializableModel @@ -142,12 +143,17 @@ class BbipItemInput(TypedDict): old_price: int -class TrxItemInput(TypedDict, total=False): - """Входные параметры одного объявления для TrxPromo-методов.""" +class _TrxItemInputRequired(TypedDict): + """Обязательные поля входных параметров TrxPromo.""" item_id: int commission: int date_from: datetime + + +class TrxItemInput(_TrxItemInputRequired, total=False): + """Входные параметры одного объявления для TrxPromo-методов.""" + date_to: datetime | None @@ -208,6 +214,11 @@ class BbipForecastsResult(SerializableModel): items: list[BbipForecast] +warnings.warn( + "PromotionForecast устарел и будет удалён. Используйте BbipForecast.", + DeprecationWarning, + stacklevel=2, +) PromotionForecast: TypeAlias = BbipForecast @@ -827,6 +838,8 @@ class CampaignUpdateTimeFilter: to_time: datetime | None = None def to_payload(self) -> dict[str, object]: + """Сериализует фильтр по времени обновления.""" + payload: dict[str, object] = {} if self.from_time is not None: payload["from"] = self.from_time.isoformat() @@ -842,6 +855,8 @@ class CampaignListFilter: by_update_time: CampaignUpdateTimeFilter | None = None def to_payload(self) -> dict[str, object]: + """Сериализует фильтр списка кампаний.""" + payload: dict[str, object] = {} if self.by_update_time is not None: payload["byUpdateTime"] = self.by_update_time.to_payload() @@ -856,6 +871,8 @@ class CampaignOrderBy: direction: str def to_payload(self) -> dict[str, object]: + """Сериализует сортировку списка кампаний.""" + return {"column": self.column, "direction": self.direction} @@ -914,4 +931,3 @@ class AutostrategyStatTotals(SerializableModel): calls: int | None views: int | None - diff --git a/avito/tariffs/domain.py b/avito/tariffs/domain.py index 837ffd0..0f73a30 100644 --- a/avito/tariffs/domain.py +++ b/avito/tariffs/domain.py @@ -14,9 +14,10 @@ class Tariff(DomainObject): """Доменный объект тарифа.""" tariff_id: int | str | None = None - user_id: int | str | None = None def get_tariff_info(self) -> TariffInfo: + """Получает информацию о тарифе аккаунта.""" + return TariffsClient(self.transport).get_tariff_info() diff --git a/tests/test_config.py b/tests/test_config.py index 9850abf..a05c9a7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -11,18 +11,12 @@ ENV_KEYS = ( "AVITO_BASE_URL", - "BASE_URL", "AVITO_USER_ID", - "USER_ID", "AVITO_AUTH__CLIENT_ID", "AVITO_AUTH__CLIENT_SECRET", "AVITO_AUTH__REFRESH_TOKEN", "AVITO_CLIENT_ID", "AVITO_CLIENT_SECRET", - "AVITO_SECRET", - "CLIENT_ID", - "CLIENT_SECRET", - "SECRET", ) @@ -70,10 +64,10 @@ def test_avito_settings_from_env_supports_alias_variables( tmp_path / ".env", "\n".join( ( - "BASE_URL=https://file.avito.ru", - "USER_ID=77", - "CLIENT_ID=file-client-id", - "SECRET=file-client-secret", + "AVITO_BASE_URL=https://file.avito.ru", + "AVITO_USER_ID=77", + "AVITO_CLIENT_ID=file-client-id", + "AVITO_CLIENT_SECRET=file-client-secret", ) ), ) @@ -197,7 +191,7 @@ def test_process_environment_overrides_dotenv_deterministically( ) monkeypatch.setenv("AVITO_BASE_URL", "https://from-env.avito.ru") monkeypatch.setenv("AVITO_CLIENT_ID", "env-client-id") - monkeypatch.setenv("AVITO_SECRET", "env-client-secret") + monkeypatch.setenv("AVITO_CLIENT_SECRET", "env-client-secret") settings = AvitoSettings.from_env(env_file=env_file) diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..e30d54a --- /dev/null +++ b/todo.md @@ -0,0 +1,126 @@ +# TODO: Устранение несоответствий STYLEGUIDE + +Анализ всех Python-файлов проекта против STYLEGUIDE.md. 12 несоответствий, упорядочены по приоритету. + +--- + +## Найденные несоответствия + +### 1. `PromotionForecast: TypeAlias = BbipForecast` без deprecation-метки +**Файл**: `avito/promotion/models.py:211` +**Правило**: «Псевдонимы типов без явной deprecation-метки запрещены». +**Действие**: Добавить `warnings.warn(DeprecationWarning)` при определении алиаса. + +--- + +### 2. Конфигурационные классы на `pydantic.BaseModel` / `BaseSettings` +**Файлы**: `avito/config.py`, `avito/auth/settings.py`, `avito/core/types.py`, `avito/core/retries.py` +**Правило**: STYLEGUIDE показывает `AvitoSettings`, `AuthSettings` как `@dataclass(slots=True, frozen=True)`. Pydantic допустим только для чтения env на границе системы — не как основа публичного SDK-объекта. +**Действие**: Переписать все четыре класса как `@dataclass(slots=True, frozen=True)` с ручным `from_env()` через уже существующий `_env.py`. + +--- + +### 3. `avito/client/` — пакет вместо файла `avito/client.py` +**Файлы**: `avito/client/__init__.py`, `avito/client/client.py` +**Правило**: Целевая архитектура STYLEGUIDE прямо указывает `avito/client.py` (файл, не пакет). +**Действие**: Перенести `avito/client/client.py` → `avito/client.py`, обновить все импорты, удалить папку. + +--- + +### 4. Даты как голый `str` без валидации формата +**Файлы**: `avito/ads/domain.py`, `avito/accounts/domain.py` +**Правило**: «Даты должны принимать `datetime` — голый `str` без проверки не допускается». +**Нарушение**: Параметры `date_from: str | None`, `date_to: str | None` в публичных методах `AdStats.*`, `Account.get_operations_history()`. +**Действие**: Изменить на `date_from: datetime | None`, преобразовывать в ISO 8601 перед передачей в transport. + +--- + +### 5. Date-поля в моделях как `str | None` вместо `datetime | None` +**Файлы**: +- `avito/accounts/models.py`: `OperationRecord.created_at` +- `avito/messenger/models.py`: `MessageInfo.created_at` +- `avito/ads/models.py`: `AutoloadReportSummary.created_at/finished_at`, `AutoloadReportDetails.created_at/finished_at` +- `avito/promotion/models.py`: `CpaAuctionItemBid.expiration_time` +**Действие**: Изменить поля на `datetime | None`, парсить в mapper-ах. + +--- + +### 6. Request-DTO в публичных сигнатурах domain-методов +**Файл**: `avito/promotion/domain.py:514-530` — `AutostrategyCampaign.list()` +**Правило**: «Request-DTO не должны появляться в публичных сигнатурах». +**Нарушение**: `filter: CampaignListFilter | None`, `order_by: list[CampaignOrderBy] | None`. +**Действие**: Раскрыть поля как keyword-only аргументы напрямую в `list()`. + +--- + +### 7. Неиспользуемые поля `user_id` (мёртвый код) +**Файлы**: +- `avito/ads/domain.py`: `AutoloadReport.user_id`, `AutoloadArchive.user_id` +- `avito/tariffs/domain.py`: `Tariff.user_id` +- `avito/promotion/domain.py`: `CpaAuction.user_id`, `PromotionOrder.user_id` +**Правило**: «Мёртвый код не допускается». +**Действие**: Удалить поля там, где они нигде не читаются. + +--- + +### 8. `TrxItemInput(TypedDict, total=False)` — неточная типизация +**Файл**: `avito/promotion/models.py:145` +**Нарушение**: `total=False` делает `item_id`, `commission`, `date_from` опциональными, хотя они обязательны. +**Действие**: Разделить на обязательную часть и `total=False`-блок только для `date_to`: +```python +class _TrxItemInputRequired(TypedDict): + item_id: int + commission: int + date_from: datetime + +class TrxItemInput(_TrxItemInputRequired, total=False): + date_to: datetime | None +``` + +--- + +### 9. `AvitoError` без `frozen=True` +**Файл**: `avito/core/exceptions.py:43` +**Нарушение**: `@dataclass(slots=True)` — несогласованно с остальными моделями SDK. +**Действие**: Добавить `frozen=True`. `__post_init__` уже использует `object.__setattr__` — совместимо. + +--- + +### 10. Тесты используют обобщённые env-алиасы `SECRET`, `BASE_URL`, `CLIENT_ID` +**Файл**: `tests/test_config.py:12-26`, `test_avito_settings_from_env_supports_alias_variables` +**Правило**: «Обобщённые имена вроде `SECRET` или `TOKEN` не должны быть официальными алиасами». +**Нарушение**: Тесты очищают `SECRET`, `CLIENT_ID`, `BASE_URL` и тестируют поведение, которого нет в `ENV_ALIASES`. +**Действие**: Удалить `BASE_URL`, `USER_ID`, `CLIENT_ID`, `SECRET`, `AVITO_SECRET` из `ENV_KEYS` и из тестовых `.env`-файлов. + +--- + +### 11. Пропущена пустая строка в `avito/core/serialization.py` +**Файл**: `avito/core/serialization.py:7-8` +**Действие**: Добавить пустую строку между `from dataclasses import fields, is_dataclass` и `def _is_public_field`. + +--- + +### 12. Отсутствуют docstring у публичных методов +**Файлы**: +- `avito/tariffs/domain.py:19` — `Tariff.get_tariff_info()` без docstring +- `avito/promotion/models.py` — `CampaignUpdateTimeFilter.to_payload()`, `CampaignListFilter.to_payload()`, `CampaignOrderBy.to_payload()` без docstring +**Действие**: Добавить однострочные docstring. + +--- + +## Порядок выполнения (по возрастанию сложности) + +| # | Файл(ы) | Сложность | +|---|---------|-----------| +| 11 | `avito/core/serialization.py` | Минимальная | +| 12 | `avito/tariffs/domain.py`, `avito/promotion/models.py` | Минимальная | +| 1 | `avito/promotion/models.py` — alias deprecation | Низкая | +| 9 | `avito/core/exceptions.py` — frozen | Низкая | +| 7 | Мёртвые `user_id` (5 файлов) | Низкая | +| 10 | `tests/test_config.py` — env aliases | Низкая | +| 8 | `avito/promotion/models.py` — TrxItemInput | Низкая | +| 6 | `avito/promotion/domain.py` — list() params | Средняя | +| 5 | Date-поля в 5 моделях | Средняя | +| 4 | Date-параметры в domain-методах | Средняя | +| 3 | `avito/client/` → `avito/client.py` | Средняя | +| 2 | Config: pydantic → dataclass (4 файла) | Высокая | From fad21ba7e82befbb3487888c8e94c4b69cdd0378 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Mon, 20 Apr 2026 23:38:54 +0300 Subject: [PATCH 12/17] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D0=B3=D0=B0=D0=B9=D0=B4=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 54 +- CLAUDE.md | 86 ++ README.md | 26 +- STYLEGUIDE.md | 792 +++++++++--------- avito/_env.py | 64 ++ avito/accounts/domain.py | 13 +- avito/accounts/mappers.py | 15 +- avito/accounts/models.py | 4 +- avito/ads/domain.py | 37 +- avito/ads/mappers.py | 21 +- avito/ads/models.py | 9 +- avito/auth/settings.py | 81 +- avito/{client => }/client.py | 41 +- avito/client/__init__.py | 5 - avito/config.py | 51 +- avito/core/retries.py | 47 +- avito/core/serialization.py | 3 + avito/core/types.py | 29 +- avito/messenger/mappers.py | 15 +- avito/messenger/models.py | 3 +- avito/promotion/domain.py | 24 +- avito/promotion/mappers.py | 6 +- avito/promotion/models.py | 3 +- tests/test_config.py | 32 + tests/test_readme_examples.py | 18 +- tests/test_stage4_datetime_domain_inputs.py | 88 ++ tests/test_stage5_datetime_model_mapping.py | 91 ++ ...test_stage6_autostrategy_list_signature.py | 73 ++ 28 files changed, 1086 insertions(+), 645 deletions(-) mode change 100644 => 120000 AGENTS.md create mode 100644 CLAUDE.md rename avito/{client => }/client.py (92%) delete mode 100644 avito/client/__init__.py create mode 100644 tests/test_stage4_datetime_domain_inputs.py create mode 100644 tests/test_stage5_datetime_model_mapping.py create mode 100644 tests/test_stage6_autostrategy_list_signature.py diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 1519028..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,53 +0,0 @@ -# Repository Guidelines - -## Project Structure & Module Organization - -The package lives in `avito/`. The repository already contains a domain-oriented SDK layout: - -- `avito/client/` contains the top-level SDK entry point. -- `avito/core/` contains shared transport, retry, exception, pagination, and shared type abstractions. -- `avito/auth/` contains auth settings, token clients, and auth provider logic. -- `avito//` packages such as `accounts/`, `ads/`, `messenger/`, `orders/`, `jobs/`, `cpa/`, `autoteka/`, `realty/`, `ratings/`, `tariffs/`, and `promotion/` contain domain models, mappers, enums, and domain clients. -- `docs/` stores Avito API reference payloads and examples in JSON/Markdown. -- `tests/` contains regression and release-gate coverage. - -Keep new code inside the `avito/` package and group it by API area (`ads/`, `messenger/`, `orders/`) as described in `STYLEGUIDE.md`. Avoid adding raw integration logic to `__init__.py`. - -## Build, Test, and Development Commands - -- `poetry install` installs runtime and developer dependencies. -- `poetry run python -m avito` runs the package entry point if you add CLI behavior. -- `make check` runs the repository quality gate: tests, mypy, ruff, and build. -- `make fmt` formats the code with Ruff. -- `poetry build` builds the wheel and source distribution. -- `make release` publishes the package after the quality gate passes. - -There is no dedicated local server or demo app in this repository. Treat `make check` as the minimum release validation step. - -## Coding Style & Naming Conventions - -Target Python is `3.14` and dependency management is handled by Poetry. Follow `STYLEGUIDE.md` for architectural rules: - -- Use 4-space indentation and full type annotations on public code. -- Prefer clear domain models over loose `dict` payloads. -- Keep HTTP, auth, mapping, and settings concerns separated. -- Use `snake_case` for functions/modules, `PascalCase` for classes, and uppercase names for environment variables such as `AVITO_CLIENT_ID`. - -`STYLEGUIDE.md` prefers dataclasses for domain models; use `pydantic-settings` only at configuration boundaries. - -## Testing Guidelines - -Automated tests are already set up under `tests/`. Add new tests with `test_*.py` names and mirror the package structure when possible, for example `tests/client/test_client.py`. - -When adding behavior, include at least one regression test and run `make check`. For API-facing changes, cover auth failures, retry behavior, and response mapping. - -## Commit & Pull Request Guidelines - -Recent commits use short, imperative summaries in Russian, for example `Подготовка к разработке`. Keep commit subjects concise, specific, and focused on one change. - -Pull requests should include: - -- a short description of the behavioral change; -- linked issue or task when available; -- notes about new environment variables, endpoints, or publishing impact; -- sample request/response snippets when API behavior changes. diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..82c154e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +make test # run all tests +make typecheck # mypy strict check on avito/ +make lint # ruff check +make fmt # ruff format +make check # test → typecheck → lint → build (full gate) +make build # poetry build + +# single test +poetry run pytest tests/test_facade.py::test_name +``` + +## Architecture + +**Entry point**: `avito/client.py` — `AvitoClient` is the single public facade. It exposes factory methods (`account()`, `ad()`, `chat()`, etc.) that return domain objects. + +**Layers** (strict separation, no mixing): + +| Layer | Location | Responsibility | +|---|---|---| +| `AvitoClient` | `avito/client.py` | Public facade, factory methods | +| `SectionClient` | `avito//client.py` | HTTP calls for one API section | +| `Transport` | `avito/core/transport.py` | httpx, retries, error mapping, token injection | +| `AuthProvider` | `avito/auth/provider.py` | Token cache, refresh, 401 handling | +| `Mapper` | `avito//mappers.py` | JSON → typed dataclass | +| Config | `avito/config.py`, `avito/auth/settings.py` | `AvitoSettings`, `AuthSettings` | + +**Domain packages** follow a uniform structure: `__init__.py`, `domain.py` (DomainObject subclass), `client.py` (SectionClient), `models.py` (frozen dataclasses), `mappers.py`, optional `enums.py`. + +**Public models** are `@dataclass(slots=True, frozen=True)`, inherit `SerializableModel` (provides `to_dict()` / `model_dump()`), and never expose transport fields. + +**Exceptions** live in `avito/core/exceptions.py`. `AvitoError` is the base. HTTP codes map to specific types: 401→`AuthenticationError`, 403→`AuthorizationError`, 429→`RateLimitError`, etc. These two are siblings, not parent/child. + +**Pagination**: `PaginatedList[T]` is lazy. First page loads on creation; subsequent pages load on iteration. `materialize()` loads all pages. + +**Testing**: `tests/fake_transport.py` provides `FakeTransport` — inject it instead of real HTTP. Tests are Arrange/Act/Assert, one scenario per test. Test names describe behavior, not the method under test. + +## API coverage and inventory + +`docs/` contains Swagger/OpenAPI specs (23 documents, 204 operations) — the authoritative source of truth for all API contracts. + +`docs/inventory.md` is the canonical mapping of every API operation to its SDK domain object and public method. Before implementing any new method, check the inventory to find: +- which `пакет_sdk` and `доменный_объект` it belongs to +- the expected `публичный_метод_sdk`, request/response type names +- whether the operation is deprecated (`deprecated: да` → wrap in a legacy domain object) + +**When adding a new API method**: add it to the `## Операции` table in `docs/inventory.md` (between the `operations-table:start/end` markers) following the existing format. + +All 204 operations from the specs must be covered. A missing method is a defect. + +## STYLEGUIDE.md — strict compliance is mandatory + +`STYLEGUIDE.md` is a normative document. All code changes **must** comply with it. When there is a conflict between any consideration and the STYLEGUIDE, the STYLEGUIDE takes priority. + +The most critical prohibitions that must never be violated: + +- Mixing layers: transport/auth/parsing/domain logic in one class. +- Returning `dict` or `Any` from public methods. +- Using `resource_id` instead of concrete names (`item_id`, `order_id`). +- Annotating `list[T]` where `PaginatedList[T]` is returned at runtime. +- Making `AuthenticationError` a subclass of `AuthorizationError` (or vice versa). +- Writing error messages in mixed languages (Russian only). +- Injecting methods via `setattr`/`globals()` at runtime. +- Duplicating behavior through two different public methods without deprecation. +- Leaking internal-layer request-DTOs into public signatures. +- Adding dead code: unused imports, type aliases, TypeVars. + +## Key conventions (from STYLEGUIDE.md) + +- All public methods return typed SDK models, never raw `dict`. +- Field names are concrete: `item_id`, `user_id` — never `resource_id`. +- Public method arguments are primitives or domain models — internal request-DTOs must not leak out. +- Write-operations that accept `dry_run: bool = False` must build the same payload in both modes; with `dry_run=True` transport must not be called. +- `Any` is forbidden except at JSON boundary layers (with a local comment). +- Error messages are written in Russian only — no mixed languages. +- No dynamic method injection (`setattr`, `globals()` patching). +- `PaginatedList[T]` annotation must match runtime — never annotate as `list[T]` if you return `PaginatedList[T]`. +- `AuthenticationError` (401) and `AuthorizationError` (403) must not be in an inheritance relation. +- Dead code (unused imports, aliases, TypeVars) must be removed. +- Request-objects of the internal layer must not appear in public domain-method signatures. diff --git a/README.md b/README.md index b27ef0e..4c39d45 100644 --- a/README.md +++ b/README.md @@ -140,13 +140,9 @@ with AvitoClient() as avito: ```python from avito import AvitoClient -from avito.promotion.models import ( - CampaignListFilter, - CampaignOrderBy, - CampaignUpdateTimeFilter, - CreateAutostrategyBudgetRequest, - ListAutostrategyCampaignsRequest, -) +from datetime import datetime + +from avito.promotion.models import CreateAutostrategyBudgetRequest with AvitoClient() as avito: services = avito.promotion_order().list_orders() @@ -161,17 +157,11 @@ with AvitoClient() as avito: ) campaign = avito.autostrategy_campaign(campaign_id=15).get() campaigns = avito.autostrategy_campaign().list( - request=ListAutostrategyCampaignsRequest( - limit=50, - status_id=[1, 2], - order_by=[CampaignOrderBy(column="startTime", direction="asc")], - filter=CampaignListFilter( - by_update_time=CampaignUpdateTimeFilter( - from_time="2026-04-01T00:00:00Z", - to_time="2026-04-30T00:00:00Z", - ) - ), - ) + limit=50, + status_id=[1, 2], + order_by=[("startTime", "asc")], + updated_from=datetime(2026, 4, 1), + updated_to=datetime(2026, 4, 30), ) print(budget.calc_id) diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index 0c987a8..7ec2b11 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -1,37 +1,37 @@ # STYLEGUIDE -## Цель +## Purpose -Этот документ задаёт единый стиль разработки Python SDK для Avito API. -Цель библиотеки: +This document defines the unified development style for the Avito API Python SDK. +The library's goals: -- дать понятный и прозрачный публичный API; -- скрыть технические детали авторизации, ретраев и обогащения данных; -- возвращать строго типизированные объекты собственных классов; -- сохранить чистую пакетную архитектуру по разделам Avito API; -- обеспечить предсказуемое поведение при нестабильном соединении. +- provide a clear and transparent public API; +- hide the technical details of authorization, retries, and data enrichment; +- return strictly typed objects of its own classes; +- maintain a clean package architecture organized by Avito API sections; +- ensure predictable behavior under unstable network conditions. -Документ нормативный. Новые модули и рефакторинг существующего кода должны соответствовать этим правилам. +This document is normative. New modules and refactoring of existing code must comply with these rules. -## Базовые принципы +## Core Principles -Принципы оформлены в порядке убывания приоритета при конфликте. +Principles are listed in descending priority order when they conflict. -- Код должен быть читаемым раньше, чем компактным. -- Явное лучше неявного: каждый публичный контракт читается без знания деталей реализации. -- Простое лучше сложного: добавлять абстракцию только тогда, когда без неё нельзя обойтись. -- Для каждой задачи должен быть один очевидный способ — не два и не три. -- Ошибки не должны проходить молча: некорректное состояние обнаруживается как можно раньше. -- Публичный API библиотеки должен быть простым, внутренние детали должны быть инкапсулированы. -- Каждый слой отвечает только за свою задачу: transport, auth, API clients, domain models, mapping, errors. -- Внешний код не должен работать с сырыми `dict[str, Any]`, если можно вернуть типизированный объект. -- Исключения должны быть явными и доменными, без `assert False` для управления потоком. -- Любое сетевое взаимодействие считается потенциально нестабильным. -- Публичные контракты SDK фиксируются явно и меняются только осознанно. +- Code must be readable before it is compact. +- Explicit over implicit: every public contract is readable without knowledge of implementation details. +- Simple over complex: add abstraction only when there is no way around it. +- For each task there must be one obvious way — not two, not three. +- Errors must not pass silently: invalid state is detected as early as possible. +- The public API of the library must be simple; internal details must be encapsulated. +- Each layer is responsible for its own task only: transport, auth, API clients, domain models, mapping, errors. +- External code must not work with raw `dict[str, Any]` when a typed object can be returned instead. +- Exceptions must be explicit and domain-specific; no `assert False` for flow control. +- All network interaction is considered potentially unstable. +- Public SDK contracts are fixed explicitly and changed only deliberately. -## Целевая архитектура пакетов +## Target Package Architecture -Разделы Avito API оформляются пакетами. Рекомендуемая структура: +Avito API sections are organized as packages. Recommended structure: ```text avito/ @@ -81,16 +81,16 @@ avito/ mappers.py ``` -Правила: +Rules: -- `core/` содержит только общую инфраструктуру, без логики конкретного API-раздела. -- Каждый раздел API живёт в отдельном пакете: `ads`, `messenger`, `orders`, `autoload` и т.д. -- В каждом разделе допускаются только модули, относящиеся к этому разделу. -- `avito/client.py` и `avito/__init__.py` содержат только высокоуровневую точку входа и публичные экспорты. +- `core/` contains only shared infrastructure, with no logic specific to any API section. +- 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. -## Публичный API библиотеки +## Public API -Публичный API должен быть объектным и очевидным: +The public API must be object-oriented and obvious: ```python client = AvitoClient(settings) @@ -99,105 +99,105 @@ listing = client.ad(item_id=42, user_id=123).get() stats = client.ad_stats(user_id=123).get_item_stats(item_ids=[42]) ``` -Правила: +Rules: -- Методы должны отражать действие предметной области, а не детали HTTP. -- Нельзя выносить в публичный API детали `headers`, `token refresh`, `raw request payload`, если в этом нет явной необходимости. -- Публичные методы возвращают доменные модели, коллекции доменных моделей или типизированные result-объекты. -- Сырые ответы API допустимы только во внутренних слоях или в явно обозначенных low-level методах. +- Methods must reflect domain actions, not HTTP details. +- `headers`, `token refresh`, `raw request payload` must not be exposed in the public API unless there is an explicit need. +- Public methods return domain models, collections of domain models, or typed result objects. +- Raw API responses are acceptable only in internal layers or in explicitly designated low-level methods. -### Один путь к каждой операции +### One Path Per Operation -Для каждой операции в публичном API должен быть ровно один очевидный способ её выполнить. Если два разных объекта делают одно и то же — это ошибка проектирования. +For each operation in the public API there must be exactly one obvious way to perform it. If two different objects do the same thing, that is a design error. -- Запрещено дублировать поведение через разные фасады: `ad().get_stats()` и `ad_stats().get_item_stats()` для одного набора данных недопустимы одновременно. -- Если один метод покрывает частный случай, а другой — общий, частный должен быть обёрткой над общим, а не независимой реализацией. -- Псевдонимы типов (`Listing = AdItem`) без явной deprecation-метки запрещены: у каждого публичного типа должно быть одно каноническое имя. +- Duplicating behavior through different facades is forbidden: `ad().get_stats()` and `ad_stats().get_item_stats()` for the same dataset cannot coexist. +- If one method covers a specific case and another covers the general case, the specific one must be a wrapper around the general one, not an independent implementation. +- Type aliases (`Listing = AdItem`) without an explicit deprecation marker are forbidden: each public type must have one canonical name. -### Что считается публичным контрактом SDK +### What Constitutes the Public SDK Contract -Нормативно в публичный контракт входят: +The following are normatively part of the public contract: -- пакет `avito` и его экспорты `AvitoClient`, `AvitoSettings`, `AuthSettings`; -- фабрики ресурсов у `AvitoClient`, например `account()`, `ad()`, `ad_stats()`, `promotion_order()`; -- публичные модели из `avito..models`; -- typed exceptions из `avito.core.exceptions`; -- lazy pagination контракт `PaginatedList`; -- стабильная сериализация публичных моделей через `to_dict()` и `model_dump()`; -- безопасный diagnostic contract метода `debug_info()`. +- the `avito` package and its exports `AvitoClient`, `AvitoSettings`, `AuthSettings`; +- resource factory methods on `AvitoClient`, e.g. `account()`, `ad()`, `ad_stats()`, `promotion_order()`; +- public models from `avito..models`; +- typed exceptions from `avito.core.exceptions`; +- the lazy pagination contract `PaginatedList`; +- stable serialization of public models via `to_dict()` and `model_dump()`; +- the safe diagnostic contract of `debug_info()`. -Нормативно не входят в публичный контракт: +The following are normatively not part of the public contract: - transport request/response shapes; -- внутренние mapper-объекты; -- `raw_payload`, служебные dataclass-ы transport-слоя и внутренние DTO; -- shape исходного JSON-ответа Avito API. +- internal mapper objects; +- `raw_payload`, transport-layer service dataclasses, and internal DTOs; +- the shape of the raw Avito API JSON response. -Внутренние изменения допустимы, пока публичные сигнатуры, возвращаемые модели, сериализация и типы ошибок остаются стабильными. +Internal changes are acceptable as long as public signatures, returned models, serialization, and exception types remain stable. -## Инициализация клиента +## Client Initialization -Пользователь должен иметь простой путь к первому рабочему вызову. +The user must have a simple path to the first working call. -Нормативно поддерживаемые способы создания клиента (от простого к явному): +Normatively supported ways to create a client (from simplest to most explicit): ```python -# 1. Из переменных окружения +# 1. From environment variables with AvitoClient.from_env() as avito: ... -# 2. Явная передача credentials — обязательный shortcut +# 2. Explicit credentials — required shortcut with AvitoClient(client_id="...", client_secret="...") as avito: ... -# 3. Полная конфигурация через settings +# 3. Full configuration via settings settings = AvitoSettings(auth=AuthSettings(client_id="...", client_secret="...")) with AvitoClient(settings) as avito: ... ``` -Правила: +Rules: -- `AvitoClient` обязан принимать `client_id` и `client_secret` напрямую без промежуточного объекта `AuthSettings`. -- `AvitoClient.from_env()` является официальным factory method для инициализации из окружения. -- Вложенность `AvitoSettings → AuthSettings` допустима как явный путь, но не должна быть единственным. +- `AvitoClient` must accept `client_id` and `client_secret` directly without requiring an intermediate `AuthSettings` object. +- `AvitoClient.from_env()` is the official factory method for initializing from the environment. +- The nested `AvitoSettings → AuthSettings` path is acceptable as an explicit option but must not be the only one. -## Классы и ответственность +## Classes and Responsibilities -Обязательное разделение: +Required separation: -- `AvitoClient` — корневой фасад SDK. -- `SectionClient` классы — клиенты конкретных API-разделов. -- `Transport` — выполнение HTTP-запросов. -- `AuthProvider` — получение и обновление токена. -- `Mapper` — преобразование JSON в доменные модели. -- `Settings`/`Config` — конфигурация SDK. +- `AvitoClient` — the root SDK facade. +- `SectionClient` classes — clients for specific API sections. +- `Transport` — HTTP request execution. +- `AuthProvider` — token acquisition and refresh. +- `Mapper` — JSON to domain model conversion. +- `Settings`/`Config` — SDK configuration. -Правила: +Rules: -- Один класс — одна явная зона ответственности. -- Классы не должны одновременно заниматься HTTP, авторизацией, логированием и преобразованием моделей. -- Запрещены "god object" классы с логикой всех API-разделов сразу. +- One class, one explicit area of responsibility. +- Classes must not simultaneously handle HTTP, authorization, logging, and model transformation. +- "God object" classes containing logic for all API sections are forbidden. -## Dataclass и модели +## Dataclasses and Models -Основной формат моделей SDK — `dataclass`. +The primary model format for the SDK is `dataclass`. -Правила: +Rules: -- Доменные сущности и объекты ответов описываются через `@dataclass(slots=True, frozen=True)` по умолчанию. -- Если модель должна быть изменяемой, это должно быть осознанным исключением и явно документироваться. -- Для списков использовать конкретные контейнеры: `list[Message]`, а не просто `list`. -- Для необязательных полей использовать `T | None`, а не неявные значения. -- Вложенные структуры тоже должны иметь собственные типизированные dataclass-модели. -- Не использовать `dict` как substitute для модели предметной области. -- Все публичные read/write методы возвращают только нормализованные модели SDK, а не transport-layer объекты. -- Для стабильных публичных моделей должны быть явно определены обязательные и nullable-поля. -- Каждая публичная модель должна предоставлять единообразную сериализацию через `to_dict()` и `model_dump()`. -- Сериализация публичных моделей должна быть JSON-compatible и рекурсивной для вложенных SDK-моделей. -- В публичных моделях запрещены transport/internal implementation fields. +- Domain entities and response objects are described with `@dataclass(slots=True, frozen=True)` by default. +- If a model must be mutable, that must be a conscious exception and explicitly documented. +- Use concrete containers for lists: `list[Message]`, not just `list`. +- Use `T | None` for optional fields, not implicit defaults. +- Nested structures must also have their own typed dataclass models. +- Do not use `dict` as a substitute for a domain model. +- All public read/write methods return only normalized SDK models, not transport-layer objects. +- For stable public models, required and nullable fields must be explicitly defined. +- Each public model must provide uniform serialization via `to_dict()` and `model_dump()`. +- Serialization of public models must be JSON-compatible and recursive for nested SDK models. +- Transport/internal implementation fields are forbidden in public models. -Пример: +Example: ```python from dataclasses import dataclass @@ -212,92 +212,92 @@ class Message: created_at: datetime ``` -## Именование полей доменных объектов +## Domain Object Field Naming -Имена полей должны точно отражать то, что в них хранится, без обобщений. +Field names must precisely reflect what they store, without generalizations. -Правила: +Rules: -- Запрещено использовать абстрактное имя `resource_id` в доменных объектах. Вместо него — конкретное имя поля: `item_id`, `user_id`, `report_id`, `order_id` и т.д. -- Если доменный объект принимает несколько идентификаторов, каждый объявляется явным полем с именем предметной области. -- Имена полей в публичных моделях не должны отражать детали HTTP или названий JSON-полей upstream API. +- The abstract name `resource_id` is forbidden in domain objects. Use a concrete field name instead: `item_id`, `user_id`, `report_id`, `order_id`, etc. +- If a domain object takes multiple identifiers, each is declared as an explicit field with a domain-specific name. +- Field names in public models must not reflect HTTP details or upstream API JSON field names. ```python -# Правильно +# Correct @dataclass(slots=True, frozen=True) class Ad(DomainObject): item_id: int | None = None user_id: int | None = None -# Неправильно +# Wrong @dataclass(slots=True, frozen=True) class Ad(DomainObject): - resource_id: int | str | None = None # что именно хранится — неизвестно + resource_id: int | str | None = None # unclear what is stored user_id: int | str | None = None ``` -## Параметры публичных методов +## Public Method Parameters -Публичный метод не должен требовать от пользователя конструировать внутренние объекты SDK. +A public method must not require the user to construct internal SDK objects. -Правила: +Rules: -- Аргументы публичных методов должны быть примитивными типами (`int`, `str`, `bool`, `float`) или хорошо известными доменными моделями результата (не request-объектами). -- Request-DTO, используемые внутри section client-ов, не должны появляться в публичных сигнатурах domain-методов. -- Если метод требует сложного входного объекта, он должен принимать его поля напрямую как keyword-only аргументы. +- Public method arguments must be primitive types (`int`, `str`, `bool`, `float`) or well-known domain result models (not request objects). +- Request-DTOs used inside section clients must not appear in public domain method signatures. +- If a method requires a complex input object, it must accept its fields directly as keyword-only arguments. ```python -# Правильно: примитивы и keyword-only +# Correct: primitives and keyword-only def create_order(self, *, item_id: int, duration: int, price: int) -> PromotionActionResult: ... -# Неправильно: внутренний request-объект утекает наружу +# Wrong: internal request object leaks out def create_order(self, *, items: list[BbipOrderItem]) -> PromotionActionResult: ... ``` -## Fail-fast и валидация состояния +## Fail-Fast and State Validation -Некорректное состояние объекта должно обнаруживаться как можно раньше. +Invalid object state must be detected as early as possible. -Правила: +Rules: -- Если доменный объект не может выполнить ни одну операцию без конкретного идентификатора, этот идентификатор должен проверяться при создании объекта, а не при первом вызове метода. -- Фабричный метод, создающий объект в заведомо неполном состоянии, должен возвращать объект с ограниченным интерфейсом (только те методы, которые доступны без ID), а не объект с методами, падающими в рантайме. -- Ошибка конфигурации (`ConfigurationError`) должна бросаться до первого HTTP-запроса. -- Даты, передаваемые как параметры, должны принимать `datetime` или валидированный строковый формат — голый `str` без проверки не допускается, если формат имеет значение. +- If a domain object cannot perform any operation without a specific identifier, that identifier must be validated at object creation time, not at the first method call. +- A factory method that creates an object in a knowingly incomplete state must return an object with a restricted interface (only the methods available without the ID), not an object with methods that fail at runtime. +- A configuration error (`ConfigurationError`) must be raised before the first HTTP request. +- Dates passed as parameters must accept `datetime` or a validated string format — a bare `str` without validation is not acceptable when the format matters. -## Pydantic и валидация +## Pydantic and Validation -Для этого проекта `dataclass` — стандарт представления доменных объектов. `pydantic` не должен быть базовым строительным блоком всей модели SDK. +For this project `dataclass` is the standard for representing domain objects. `pydantic` must not be the foundational building block of the entire SDK model. -Допустимое использование `pydantic`: +Acceptable uses of `pydantic`: -- чтение конфигурации из environment; -- валидация сложного внешнего payload на границе системы, если это действительно упрощает код; -- прототипирование до появления финальной dataclass-модели. +- reading configuration from the environment; +- validating complex external payloads at the system boundary, if it genuinely simplifies the code; +- prototyping before the final dataclass model exists. -Недопустимое использование: +Unacceptable uses: -- смешивать `pydantic.BaseModel` и `dataclass` без чёткого слоя ответственности; -- возвращать `BaseModel` как основной публичный формат SDK, если доменная dataclass уже существует. +- mixing `pydantic.BaseModel` and `dataclass` without a clear layer of responsibility; +- returning `BaseModel` as the primary public SDK format when a domain dataclass already exists. -## Типизация и mypy +## Typing and mypy -Строгая типизация обязательна. +Strict typing is mandatory. -Правила: +Rules: -- Все функции, методы, атрибуты классов и возвращаемые значения должны быть аннотированы. -- `Any` запрещён, кроме узких boundary-layer мест с локальным объяснением. -- Использовать `mypy` в строгом режиме или максимально близком к нему. -- Использовать `Protocol`, `TypeAlias`, `TypedDict` для границ, где dataclass ещё не применим. -- JSON от внешнего API сначала трактуется как boundary-тип, затем маппится в dataclass. -- Не возвращать объединения слишком широких типов вроде `dict | list | str | None`. -- Аннотация возвращаемого типа должна точно соответствовать типу значения в рантайме. Если метод возвращает `PaginatedList`, аннотация должна содержать `PaginatedList`, а не `list`. -- Мёртвый код не допускается: неиспользуемые `TypeVar`, импорты и псевдонимы должны быть удалены. +- All functions, methods, class attributes, and return values must be annotated. +- `Any` is forbidden except in narrow boundary-layer locations with a local explanation. +- Use `mypy` in strict mode or as close to it as possible. +- Use `Protocol`, `TypeAlias`, `TypedDict` at boundaries where a dataclass is not yet applicable. +- JSON from an external API is first treated as a boundary type, then mapped to a dataclass. +- Do not return overly wide type unions such as `dict | list | str | None`. +- The return type annotation must precisely match the type of the value at runtime. If a method returns `PaginatedList`, the annotation must contain `PaginatedList`, not `list`. +- Dead code is not allowed: unused `TypeVar`s, imports, and aliases must be removed. -Минимальный целевой профиль `mypy`: +Minimum target `mypy` profile: ```toml [tool.mypy] @@ -310,50 +310,50 @@ disallow_any_generics = true no_implicit_optional = true ``` -## HTTP и transport layer +## HTTP and Transport Layer -Весь HTTP должен проходить через единый transport layer. +All HTTP must go through a single transport layer. -Правила: +Rules: -- Прямые вызовы `httpx.get()`/`httpx.post()` внутри section clients запрещены. -- Использовать `httpx.Client` или `httpx.AsyncClient` как внутреннюю зависимость transport-слоя. -- Таймауты задаются явно. -- Заголовки авторизации подставляются transport/auth слоем, а не бизнес-методами. -- Формирование URL, обработка ошибок, retry и логирование концентрируются в transport. -- Transport-детали не должны быть частью публичных сигнатур, docstrings и serialization. +- Direct calls to `httpx.get()`/`httpx.post()` inside section clients are forbidden. +- Use `httpx.Client` or `httpx.AsyncClient` as an internal dependency of the transport layer. +- Timeouts are set explicitly. +- Authorization headers are injected by the transport/auth layer, not by business methods. +- URL construction, error handling, retries, and logging are concentrated in the transport. +- Transport details must not be part of public signatures, docstrings, or serialization. -Рекомендация: +Recommendation: -- Сначала сделать качественный sync SDK. -- SDK является синхронным — это должно быть явно задокументировано в README и публичном API. -- Async-версию добавлять отдельным слоем, а не смешивать sync/async в одних и тех же классах. +- Build a high-quality sync SDK first. +- The SDK is synchronous — this must be explicitly documented in the README and public API. +- An async version should be added as a separate layer, not mixed with sync in the same classes. -## Авторизация +## Authorization -Авторизация должна быть полностью абстрагирована от API-методов. +Authorization must be fully abstracted away from API methods. -Правила: +Rules: -- API-методы не должны сами получать токен. -- Должен существовать отдельный `AuthProvider`, отвечающий за кэш токена, refresh и срок жизни. -- При `401 Unauthorized` transport должен инициировать controlled refresh, а не ломать контракт случайным образом. -- Конфигурация авторизации хранится в `Settings`, а не размазывается по коду. +- API methods must not fetch tokens themselves. +- A separate `AuthProvider` must exist, responsible for token caching, refresh, and lifetime. +- On `401 Unauthorized`, the transport must initiate a controlled refresh rather than breaking the contract unpredictably. +- Authorization configuration is stored in `Settings`, not scattered throughout the code. -## Работа с нестабильным соединением +## Handling Unstable Connections -Сеть нестабильна по умолчанию. Это нужно считать частью дизайна. +The network is unstable by default. This must be treated as part of the design. -Правила: +Rules: -- На transport-уровне должны быть retries с ограничением числа попыток. -- Retry применяется только к безопасным сценариям: timeout, connection errors, временные `5xx`, rate limit при понятной политике. -- Политика retry должна быть централизована и конфигурируема. -- Для всех запросов задаются разумные timeout'ы на connect/read/write. -- Ошибки после исчерпания retry не скрываются, а поднимаются как доменные исключения. -- Логирование retry должно быть информативным, но без утечки секретов. +- The transport layer must have retries with a bounded number of attempts. +- Retries apply only to safe scenarios: timeout, connection errors, transient `5xx`, rate limiting under a clear policy. +- The retry policy must be centralized and configurable. +- Reasonable timeouts for connect/read/write are set on all requests. +- Errors after retry exhaustion are not suppressed; they are raised as domain exceptions. +- Retry logging must be informative but must not leak secrets. -Минимально ожидаемые сущности: +Minimum expected entities: - `RetryPolicy` - `ApiTimeouts` @@ -362,29 +362,29 @@ no_implicit_optional = true - `AuthorizationError` - `UpstreamApiError` -## Ошибки и исключения +## Errors and Exceptions -`assert` не используется для обработки ошибок API. +`assert` is not used to handle API errors. -Правила: +Rules: -- Для ошибок SDK создаётся иерархия собственных исключений в `core/exceptions.py`. -- Ошибка должна содержать минимум: `operation`, HTTP status, код ошибки Avito при наличии, человекочитаемое сообщение и безопасные metadata. -- Ошибки 4xx и 5xx должны различаться типами. -- Ошибки парсинга и ошибки transport должны различаться. -- Mapping transport/HTTP/API ошибок в публичные ошибки SDK должен быть централизован. -- Секреты, токены и чувствительные headers должны автоматически санитизироваться в сообщении и metadata. -- Неизвестная ошибка upstream не должна протекать наружу как сырой transport exception. -- Все сообщения об ошибках пишутся на одном языке — русском. Смешивание языков в сообщениях об ошибках запрещено. +- A hierarchy of custom exceptions is created in `core/exceptions.py` for SDK errors. +- An error must contain at minimum: `operation`, HTTP status, Avito error code if present, a human-readable message, and safe metadata. +- `4xx` and `5xx` errors must be distinguished by type. +- Parsing errors and transport errors must be distinguished. +- Mapping of transport/HTTP/API errors to public SDK errors must be centralized. +- Secrets, tokens, and sensitive headers must be automatically sanitized in the message and metadata. +- An unknown upstream error must not leak out as a raw transport exception. +- All error messages are written in a single language — Russian. Mixing languages in error messages is forbidden. -Пример иерархии: +Example hierarchy: ```python class AvitoError(Exception): ... class TransportError(AvitoError): ... class ValidationError(AvitoError): ... -class AuthorizationError(AvitoError): ... # 403: недостаточно прав -class AuthenticationError(AvitoError): ... # 401: неверные credentials / токен +class AuthorizationError(AvitoError): ... # 403: insufficient permissions +class AuthenticationError(AvitoError): ... # 401: invalid credentials / token class RateLimitError(AvitoError): ... class ConflictError(AvitoError): ... class UnsupportedOperationError(AvitoError): ... @@ -392,53 +392,53 @@ class UpstreamApiError(AvitoError): ... class ResponseMappingError(AvitoError): ... ``` -`AuthenticationError` (401) и `AuthorizationError` (403) — семантически разные ошибки, они не должны находиться в отношении наследования. Пользователь, ловящий `AuthorizationError`, не должен неожиданно получать ошибки аутентификации. +`AuthenticationError` (401) and `AuthorizationError` (403) are semantically different errors; they must not be in an inheritance relationship. A user catching `AuthorizationError` must not unexpectedly receive authentication errors. -Нормативный mapping: +Normative mapping: -- `400` и `422` маппятся в `ValidationError`, если это соответствует контракту операции; -- `401` маппится в `AuthenticationError`; -- `403` маппится в `AuthorizationError`; -- `409` маппится в `ConflictError`; -- `429` маппится в `RateLimitError`; -- неподдерживаемая операция приводит к `UnsupportedOperationError`; -- остальные неизвестные ошибки upstream маппятся в `UpstreamApiError`. +- `400` and `422` map to `ValidationError` when that matches the operation's contract; +- `401` maps to `AuthenticationError`; +- `403` maps to `AuthorizationError`; +- `409` maps to `ConflictError`; +- `429` maps to `RateLimitError`; +- an unsupported operation results in `UnsupportedOperationError`; +- all other unknown upstream errors map to `UpstreamApiError`. -## Mapping и преобразование данных +## Mapping and Data Transformation -JSON от Avito — это внешний контракт, а не внутренняя модель приложения. +JSON from Avito is an external contract, not an internal application model. -Правила: +Rules: -- Сырые JSON-ответы маппятся в отдельном слое. -- Логика "обогащения" данных выполняется после transport, но до возврата объекта пользователю. -- Обогащение должно быть детерминированным и не ломать исходный контракт метода. -- Если обогащение дорогое или требует дополнительных запросов, оно должно быть явно обозначено в API. -- Централизовать преобразование transport response в публичные модели SDK. -- Один и тот же ресурс должен маппиться в один и тот же публичный тип независимо от вариаций upstream payload внутри допустимого диапазона. -- Публичные docstring и сигнатуры не должны требовать знания upstream JSON shape. +- Raw JSON responses are mapped in a dedicated layer. +- Data enrichment logic executes after transport but before returning the object to the user. +- Enrichment must be deterministic and must not break the original method contract. +- If enrichment is expensive or requires additional requests, it must be explicitly indicated in the API. +- Transformation of transport responses into public SDK models must be centralized. +- The same resource must always map to the same public type, regardless of upstream payload variations within the allowed range. +- Public docstrings and signatures must not require knowledge of the upstream JSON shape. -Рекомендация: +Recommendation: -- Использовать `mappers.py` внутри раздела API. -- Не смешивать mapping с HTTP-вызовом в одном методе. +- Use `mappers.py` inside each API section. +- Do not mix mapping with the HTTP call in the same method. -## Публичные read-контракты +## Public Read Contracts -Read-операции должны быть выровнены по форме результата, nullable-поведению и неймингу полей. +Read operations must be aligned in result shape, nullable behavior, and field naming. -Правила: +Rules: -- `account().get_self()` возвращает `AccountProfile`; -- `ad().get(...)` возвращает `Listing`; -- `ad().list(...)` возвращает коллекцию или пагинируемый результат из `Listing`; -- `ad_stats().get_item_stats(...)` возвращает коллекцию `ListingStats`; -- `ad_stats().get_calls_stats(...)` возвращает коллекцию `CallStats`; -- `ad_stats().get_account_spendings(...)` возвращает `AccountSpendings` или иную зафиксированную контрактом SDK модель; -- пустой или частично заполненный upstream payload не должен ломать read-контракт, если модель допускает `None` для отсутствующих значений; -- consumer-код не должен знать структуру raw Avito response для использования read-методов. +- `account().get_self()` returns `AccountProfile`; +- `ad().get(...)` returns `Listing`; +- `ad().list(...)` returns a collection or paginated result of `Listing`; +- `ad_stats().get_item_stats(...)` returns a collection of `ListingStats`; +- `ad_stats().get_calls_stats(...)` returns a collection of `CallStats`; +- `ad_stats().get_account_spendings(...)` returns `AccountSpendings` or another model fixed by the SDK contract; +- an empty or partially populated upstream payload must not break the read contract if the model allows `None` for missing values; +- consumer code must not need to know the raw Avito response structure to use read methods. -Для стабильных публичных read/write результатов нормативно закрепляются следующие canonical типы: +The following canonical types are normatively fixed for stable public read/write results: - `AccountProfile` - `Listing` @@ -450,20 +450,20 @@ Read-операции должны быть выровнены по форме - `PromotionForecast` - `PromotionActionResult` -## Promotion write-контракт +## Promotion Write Contract -Официально поддерживаемые write-операции продвижения должны иметь единый публичный контракт. +Officially supported promotion write operations must have a unified public contract. -Правила: +Rules: -- write-операции продвижения принимают `dry_run: bool = False`; -- при `dry_run=True` метод обязан валидировать входные данные, собрать официальный request payload, не выполнять write-запрос и вернуть `PromotionActionResult` со статусом `preview` или `validated`; -- при `dry_run=False` метод обязан использовать тот же payload builder, выполнить write-запрос и вернуть тот же тип `PromotionActionResult`; -- невалидные входные параметры должны приводить к `ValidationError` до вызова transport; -- `request_payload` в результате должен соответствовать фактическому payload write-вызова; -- одинаковые входы в `dry_run=True` и `dry_run=False` должны формировать один и тот же payload. +- Promotion write operations accept `dry_run: bool = False`; +- with `dry_run=True` the method must validate inputs, build the official request payload, not execute the write request, and return `PromotionActionResult` with status `preview` or `validated`; +- with `dry_run=False` the method must use the same payload builder, execute the write request, and return the same type `PromotionActionResult`; +- invalid input parameters must result in `ValidationError` before transport is called; +- `request_payload` in the result must correspond to the actual payload of the write call; +- identical inputs in `dry_run=True` and `dry_run=False` must produce the same payload. -Стабильный контракт `PromotionActionResult`: +Stable `PromotionActionResult` contract: - `action` - `target` @@ -474,7 +474,7 @@ Read-операции должны быть выровнены по форме - `upstream_reference` - `details` -Минимум следующие операции должны следовать этому контракту: +At minimum the following operations must follow this contract: - `bbip_promotion().create_order(...)` - `ad_promotion().apply_vas(...)` @@ -486,49 +486,49 @@ Read-операции должны быть выровнены по форме - `target_action_pricing().update_auto(...)` - `target_action_pricing().delete(...)` -## Promotion read-контракт +## Promotion Read Contract -Read-операции promotion surface должны возвращать только стабильные публичные SDK-модели. +Promotion surface read operations must return only stable public SDK models. -Правила: +Rules: -- `promotion_order().list_services(...)` возвращает коллекцию `PromotionService`; -- `promotion_order().list_orders(...)` возвращает коллекцию `PromotionOrder`; -- `promotion_order().get_order_status(...)` возвращает результат по зафиксированному контракту SDK; -- `bbip_promotion().get_suggests(...)` и `bbip_promotion().get_forecasts(...)` возвращают стабильные SDK-модели, а не transport shape; -- `target_action_pricing().get_bids(...)` и `target_action_pricing().get_promotions_by_item_ids(...)` возвращают стабильные SDK-модели; -- пустой список upstream корректно возвращается как пустая коллекция SDK-моделей; -- частичный upstream payload корректно маппится в nullable-поля публичных моделей. +- `promotion_order().list_services(...)` returns a collection of `PromotionService`; +- `promotion_order().list_orders(...)` returns a collection of `PromotionOrder`; +- `promotion_order().get_order_status(...)` returns a result per the fixed SDK contract; +- `bbip_promotion().get_suggests(...)` and `bbip_promotion().get_forecasts(...)` return stable SDK models, not transport shapes; +- `target_action_pricing().get_bids(...)` and `target_action_pricing().get_promotions_by_item_ids(...)` return stable SDK models; +- an empty upstream list is correctly returned as an empty SDK model collection; +- a partial upstream payload is correctly mapped into nullable fields of the public model. -## Нейминг +## Naming -Правила: +Rules: -- Имена пакетов и модулей: lowercase, короткие и предметные. -- Имена классов: `PascalCase`. -- Имена функций и методов: `snake_case`. -- Имена публичных методов должны описывать бизнес-действие: `get_item`, `list_messages`, `create_discount_campaign`. -- Для публичных моделей использовать canonical имена предметной области, а не внутренние transport aliases. -- Избегать абстрактных имён вроде `utils`, `helpers`, `common2`, `manager2`. -- Запрещены generic-имена для идентификаторов: `resource_id`, `entity_id`, `obj_id`. Использовать конкретные имена: `item_id`, `order_id`, `user_id`. +- Package and module names: lowercase, short, and domain-specific. +- Class names: `PascalCase`. +- Function and method names: `snake_case`. +- Public method names must describe the business action: `get_item`, `list_messages`, `create_discount_campaign`. +- Use canonical domain names for public models, not internal transport aliases. +- Avoid abstract names like `utils`, `helpers`, `common2`, `manager2`. +- Generic identifier names are forbidden: `resource_id`, `entity_id`, `obj_id`. Use concrete names: `item_id`, `order_id`, `user_id`. -## Конфигурация +## Configuration -Правила: +Rules: -- Конфигурация SDK выделяется в отдельный модуль: `config.py` или `settings.py`. -- `AvitoSettings` и `AuthSettings` являются официальным способом конфигурации SDK. -- Пользователь SDK должен иметь возможность передать `client_id` и `client_secret` напрямую в `AvitoClient` без создания промежуточных объектов. -- Переменные окружения читаются в одном месте через `AvitoSettings.from_env()` и `AuthSettings.from_env()`. -- `AvitoClient.from_env()` является официальным factory method для инициализации клиента из environment. -- Resolution process environment и `.env` должен быть детерминированным и одинаковым для всех entry point. -- Значения из process environment имеют приоритет над `.env`. -- Поддерживаемые env-переменные и alias-имена должны быть задокументированы и считаться частью стабильного config contract. -- Отсутствие обязательных полей конфигурации должно валидироваться до первого HTTP-запроса через typed exceptions с понятными сообщениями. -- Сообщения и metadata ошибок конфигурации не должны содержать секретные значения. -- Количество допустимых env-переменных-синонимов для одного поля должно быть минимальным. Обобщённые имена вроде `SECRET` или `TOKEN` не должны быть официальными алиасами. +- SDK configuration is isolated in a dedicated module: `config.py` or `settings.py`. +- `AvitoSettings` and `AuthSettings` are the official way to configure the SDK. +- SDK users must be able to pass `client_id` and `client_secret` directly to `AvitoClient` without creating intermediate objects. +- Environment variables are read in one place via `AvitoSettings.from_env()` and `AuthSettings.from_env()`. +- `AvitoClient.from_env()` is the official factory method for initializing the client from the environment. +- Resolution of process environment and `.env` must be deterministic and identical for all entry points. +- Process environment values take priority over `.env`. +- Supported environment variables and alias names must be documented and considered part of the stable config contract. +- Missing required configuration fields must be validated before the first HTTP request, via typed exceptions with clear messages. +- Error messages and metadata for configuration errors must not contain secret values. +- The number of allowed environment variable synonyms for a single field must be minimal. Generic names like `SECRET` or `TOKEN` must not be official aliases. -Пример: +Example: ```python @dataclass(slots=True, frozen=True) @@ -546,120 +546,120 @@ class AvitoSettings: timeout_seconds: float = 10.0 ``` -Минимально ожидаемые возможности config contract: +Minimum expected config contract capabilities: - `AvitoSettings.from_env()`; - `AuthSettings.from_env()`; - `AvitoClient.from_env()`; - `AvitoClient(client_id=..., client_secret=...)`; -- явная валидация обязательных auth-полей; -- безопасный `debug_info()` contract без утечки `client_secret`, access token, refresh token и `Authorization` header. +- explicit validation of required auth fields; +- safe `debug_info()` contract with no leakage of `client_secret`, access token, refresh token, or `Authorization` header. -## Пагинация +## Pagination -Публичное поведение lazy pagination должно быть зафиксировано как часть SDK contract. +The public behavior of lazy pagination must be fixed as part of the SDK contract. -Правила: +Rules: -- list-методы, использующие lazy pagination, возвращают результат с list-like коллекцией `PaginatedList` в поле `items`; -- аннотация типа поля `items` должна быть `PaginatedList[T]`, а не `list[T]` — аннотация должна соответствовать рантайму; -- первая страница может быть уже загружена в момент получения результата; -- чтение первых `N` элементов не должно загружать все страницы сразу; -- итерация по первым `N` элементам должна выполнять только необходимое число page-запросов; -- полная материализация должна выполняться явным вызовом, например `items.materialize()`; -- пустая коллекция должна работать без лишних запросов; -- ошибка последующей страницы должна пробрасываться в момент чтения этой страницы; -- повторный доступ к уже загруженным элементам не должен инициировать повторный fetch, если кэширование объявлено частью контракта. +- list methods using lazy pagination return a result with a list-like `PaginatedList` collection in the `items` field; +- the type annotation for the `items` field must be `PaginatedList[T]`, not `list[T]` — the annotation must match the runtime; +- the first page may already be loaded at the time the result is obtained; +- reading the first `N` elements must not load all pages at once; +- iterating over the first `N` elements must execute only the necessary number of page requests; +- full materialization must be performed by an explicit call, e.g. `items.materialize()`; +- an empty collection must work without extra requests; +- an error on a subsequent page must propagate at the time that page is read; +- repeated access to already-loaded elements must not trigger a re-fetch if caching is declared part of the contract. -Если поверх пагинации нужны дополнительные утилиты, они должны быть частью публичного SDK contract, а не внешними helper-функциями. +If additional utilities are needed on top of pagination, they must be part of the public SDK contract, not external helper functions. -## Сериализация +## Serialization -Публичные модели SDK должны безопасно и единообразно сериализоваться без внешних helper-ов. +Public SDK models must serialize safely and uniformly without external helpers. -Правила: +Rules: -- каждая публичная модель сериализуется стандартным SDK-методом; -- результат сериализации должен быть JSON-compatible; -- вложенные публичные модели должны сериализоваться рекурсивно; -- nullable и optional-поля сериализуются по правилам зафиксированного контракта; -- сериализация не должна раскрывать transport-объекты, служебные ссылки и внутренние mapper-поля; -- методы `to_dict()` и `model_dump()` должны быть явно объявлены в классе или унаследованы от явного mixin — динамическое добавление методов через `globals()` или `setattr` в рантайме запрещено; -- наличие методов сериализации должно быть видно в определении класса без необходимости отслеживать побочные вызовы при импорте модуля. +- each public model serializes via a standard SDK method; +- the serialization result must be JSON-compatible; +- nested public models must serialize recursively; +- nullable and optional fields serialize per the fixed contract rules; +- serialization must not expose transport objects, service references, or internal mapper fields; +- `to_dict()` and `model_dump()` must be explicitly declared in the class or inherited from an explicit mixin — dynamic method injection via `globals()` or `setattr` at runtime is forbidden; +- the presence of serialization methods must be visible in the class definition without tracking side-effect calls during module import. -## Логирование +## Logging -Правила: +Rules: -- Логирование должно быть структурным и полезным для диагностики. -- Нельзя логировать `client_secret`, access token, refresh token, полный authorization header и иные секреты. -- На уровне info/debug можно логировать endpoint, attempt number, latency, status code и operation name. -- Пользователь SDK должен иметь возможность отключить или перенаправить логирование. -- Диагностические снимки, например `debug_info()`, должны считаться безопасными по умолчанию. +- Logging must be structured and useful for diagnostics. +- `client_secret`, access token, refresh token, the full authorization header, and other secrets must not be logged. +- At info/debug level it is acceptable to log endpoint, attempt number, latency, status code, and operation name. +- SDK users must be able to disable or redirect logging. +- Diagnostic snapshots such as `debug_info()` must be safe by default. -## Докстринги и комментарии +## Docstrings and Comments -Правила: +Rules: -- Публичные классы и методы должны иметь короткие docstring с описанием контракта. -- Docstring публичного метода должен описывать возвращаемую SDK-модель и поведение на nullable/empty cases. -- Комментарии используются только там, где нельзя выразить намерение кодом. -- Комментарии не должны дублировать очевидное. +- Public classes and methods must have short docstrings describing the contract. +- A public method docstring must describe the returned SDK model and behavior on nullable/empty cases. +- Comments are used only where the intent cannot be expressed in code. +- Comments must not duplicate what is obvious. -## Тестирование +## Testing -### Что тестировать +### What to Test -Тест существует, чтобы зафиксировать техническое решение или контракт. Тест оправдан, если без него можно незаметно сломать поведение, которое важно для пользователя или для работы системы. +A test exists to fix a technical decision or contract. A test is justified if without it, behavior that matters to the user or to the system can be broken unnoticed. -Тестируются: +What is tested: -- **Публичный контракт SDK**: сигнатуры фабричных методов, возвращаемые типы, поведение на пустом и частичном upstream payload. -- **Маппинг ошибок**: каждый значимый HTTP статус должен приводить к строго определённому типу исключения SDK; секреты в metadata не должны утекать. -- **Auth flow**: получение токена, refresh после 401, использование отдельных credentials для специализированных endpoint-ов. -- **Retry-логика**: retry срабатывает на допустимых сценариях (timeout, 5xx, rate limit) и не срабатывает на недопустимых (non-idempotent методы без явного разрешения). -- **Пагинация**: ленивая загрузка читает только нужные страницы; ошибка на последующей странице пробрасывается в момент чтения; пустая коллекция не инициирует лишних запросов; полная материализация загружает все страницы ровно один раз. -- **Сериализация**: `to_dict()` / `model_dump()` возвращает JSON-compatible структуру; transport-поля не попадают в результат; вложенные модели сериализуются рекурсивно. -- **Dry-run контракт**: при `dry_run=True` transport не вызывается; payload, сформированный в `dry_run` и в реальном вызове, идентичен при одинаковых входных данных. -- **Конфигурация**: обязательные поля проверяются до первого HTTP-запроса; приоритет process environment над `.env` детерминирован; секреты не попадают в `debug_info()`. -- **Безопасность данных**: секретные значения (токены, `client_secret`, `Authorization` header) не попадают ни в сообщения об ошибках, ни в metadata, ни в serialization output. +- **Public SDK contract**: factory method signatures, return types, behavior on empty and partial upstream payloads. +- **Error mapping**: each significant HTTP status must result in a strictly defined SDK exception type; secrets must not leak into metadata. +- **Auth flow**: token acquisition, refresh after 401, use of separate credentials for specialized endpoints. +- **Retry logic**: retries fire on allowed scenarios (timeout, 5xx, rate limit) and do not fire on disallowed ones (non-idempotent methods without explicit permission). +- **Pagination**: lazy loading reads only the necessary pages; an error on a subsequent page propagates at read time; an empty collection triggers no extra requests; full materialization loads all pages exactly once. +- **Serialization**: `to_dict()` / `model_dump()` returns a JSON-compatible structure; transport fields do not appear in the result; nested models serialize recursively. +- **Dry-run contract**: with `dry_run=True` transport is not called; the payload built in `dry_run` and in a real call is identical given the same inputs. +- **Configuration**: required fields are validated before the first HTTP request; process environment priority over `.env` is deterministic; secrets do not appear in `debug_info()`. +- **Data security**: secret values (tokens, `client_secret`, `Authorization` header) do not appear in error messages, metadata, or serialization output. -Не тестируются: +What is not tested: -- Что конструктор принимает аргументы и сохраняет их в поля. -- Что dataclass содержит поле с определённым типом. -- Что функция возвращает `None`, если входное значение `None`. -- Что импорт модуля не падает. -- Логика, полностью реализованная сторонней библиотекой без кастомизации. -- Соответствие кода документации: тест не должен проверять, что README, inventory, docstring или комментарий описывает текущее поведение. Документация не является контрактом — она описывает код, а не наоборот. Если документация устарела, её нужно обновить, а не писать тест, который за этим следит. -- Наличие конкретного имени метода или атрибута через `hasattr`. Это проверка синтаксиса, а не поведения. Если метод переименован — сломается вызывающий код, а не тест на `hasattr`. +- That a constructor accepts arguments and stores them in fields. +- That a dataclass contains a field of a certain type. +- That a function returns `None` when the input is `None`. +- That importing a module does not raise an exception. +- Logic fully implemented by a third-party library without customization. +- Code-to-documentation consistency: a test must not verify that a README, inventory, docstring, or comment describes the current behavior. Documentation is not a contract — it describes code, not the other way around. If documentation is outdated, update it; do not write a test to track it. +- The presence of a specific method or attribute via `hasattr`. That is a syntax check, not a behavior check. If a method is renamed, the calling code will break, not a `hasattr` test. -Критерий: если тест невозможно сломать, не нарушив публичный контракт или техническое решение, — тест не нужен. +Criterion: if a test cannot be broken without violating a public contract or technical decision, the test is not needed. -### Архитектура тестов +### Test Architecture -Тесты делятся по тому, что они проверяют, а не по тому, какой модуль они покрывают. +Tests are divided by what they verify, not by which module they cover. -**Уровни тестов:** +**Test levels:** -- **Contract tests** — проверяют, что публичный API возвращает ожидаемые типы и структуры при корректном upstream payload. Используют fake transport. Не зависят от сети. -- **Error mapping tests** — проверяют, что каждый HTTP статус и каждый upstream error shape приводит к правильному типу исключения SDK с ожидаемыми полями. -- **Integration-style tests** — проверяют сквозные технические решения: retry, auth refresh, pagination. Используют контролируемый fake transport с заданными сценариями. -- **Security tests** — проверяют, что секреты не утекают ни через какой публичный путь: ошибки, serialization, debug_info. +- **Contract tests** — verify that the public API returns expected types and structures given correct upstream payloads. Use fake transport. Do not depend on the network. +- **Error mapping tests** — verify that each HTTP status and upstream error shape results in the correct SDK exception type with the expected fields. +- **Integration-style tests** — verify end-to-end technical decisions: retry, auth refresh, pagination. Use a controlled fake transport with specified scenarios. +- **Security tests** — verify that secrets do not leak through any public path: errors, serialization, debug_info. -### Изоляция +### Isolation -Тесты не ходят в сеть. Весь HTTP заменяется управляемым fake transport, который: +Tests do not make network calls. All HTTP is replaced by a controlled fake transport that: -- получает на вход заданный статус и payload для каждого запроса; -- позволяет проверить, был ли вызов совершён, сколько раз, с каким методом и телом; -- одинаково используется во всех тестах, которые проверяют публичный API. +- accepts a specified status and payload for each request; +- allows verifying whether a call was made, how many times, with which method and body; +- is used uniformly across all tests that verify the public API. -Секция clients, domain objects и transport тестируются изолированно друг от друга. +Section clients, domain objects, and transport are tested in isolation from each other. -### Структура теста +### Test Structure -Каждый тест проверяет один аспект поведения. Структура — Arrange / Act / Assert без вложенных условий. +Each test verifies one aspect of behavior. Structure: Arrange / Act / Assert with no nested conditionals. ```python def test_transport_retries_on_server_error_and_raises_after_exhaustion(): @@ -677,74 +677,88 @@ def test_transport_retries_on_server_error_and_raises_after_exhaustion(): assert transport.call_count == 3 ``` -Правила: +Rules: -- Имя теста описывает поведение, а не проверяемый метод: `test_transport_retries_on_server_error_and_raises_after_exhaustion`, а не `test_transport_request`. -- Один тест — один сценарий. Несколько `assert` допустимы, если они проверяют одно и то же поведение с разных сторон. -- Параметризация используется для набора эквивалентных входных данных: разные HTTP статусы, разные error shapes, разные варианты upstream payload. -- Фикстуры создают только инфраструктуру (fake transport, settings), но не скрывают логику теста. +- Test names describe behavior, not the method under test: `test_transport_retries_on_server_error_and_raises_after_exhaustion`, not `test_transport_request`. +- One test, one scenario. Multiple `assert` statements are acceptable when they verify the same behavior from different angles. +- Parametrization is used for sets of equivalent inputs: different HTTP statuses, different error shapes, different upstream payload variants. +- Fixtures create only infrastructure (fake transport, settings); they must not hide test logic. -### Покрытие обязательных сценариев +### Coverage of Mandatory Scenarios -**Маппинг ошибок** — обязательно покрыть: +**Error mapping** — must cover: -- 400, 401, 403, 404, 409, 422, 429, 5xx → соответствующий тип исключения SDK; -- секреты в `metadata` и `headers` ошибки заменяются на `***`; -- неизвестный статус маппится в `UpstreamApiError`, не в generic `Exception`. +- 400, 401, 403, 404, 409, 422, 429, 5xx → the corresponding SDK exception type; +- secrets in `metadata` and error `headers` are replaced with `***`; +- an unknown status maps to `UpstreamApiError`, not a generic `Exception`. -**Auth flow** — обязательно покрыть: +**Auth flow** — must cover: -- успешное получение токена по `client_credentials`; -- автоматический refresh после 401 ровно один раз; -- `AuthenticationError` после неудачного refresh (повторный 401); -- изоляция credentials для отдельных token endpoint-ов. +- successful token acquisition via `client_credentials`; +- automatic refresh after 401 exactly once; +- `AuthenticationError` after a failed refresh (second 401); +- credential isolation for separate token endpoints. -**Пагинация** — обязательно покрыть: +**Pagination** — must cover: -- частичная итерация загружает только нужные страницы; -- полная материализация через `materialize()` загружает всё ровно один раз; -- пустая первая страница не вызывает дополнительных запросов; -- ошибка на последующей странице пробрасывается при чтении, а не при создании объекта. +- partial iteration loads only the necessary pages; +- full materialization via `materialize()` loads everything exactly once; +- an empty first page triggers no additional requests; +- an error on a subsequent page propagates at read time, not at object creation. -**Dry-run** — обязательно покрыть для каждого write-метода с `dry_run`: +**Dry-run** — must cover for each write method with `dry_run`: -- при `dry_run=True` transport не получает ни одного вызова; -- payload в `dry_run=True` и `dry_run=False` при одинаковых входных данных идентичен; -- валидация входных данных при `dry_run=True` работает так же, как при `dry_run=False`. +- with `dry_run=True` transport receives no calls; +- the payload in `dry_run=True` and `dry_run=False` is identical given the same inputs; +- input validation in `dry_run=True` works the same as in `dry_run=False`. -**Сериализация** — обязательно покрыть: +**Serialization** — must cover: -- `to_dict()` возвращает только публичные поля без transport-объектов; -- вложенные модели сериализуются рекурсивно; -- результат проходит `json.dumps()` без исключений. +- `to_dict()` returns only public fields with no transport objects; +- nested models serialize recursively; +- the result passes `json.dumps()` without exceptions. -## Импорты и зависимости +## API Documentation and Contract Coverage -Правила: +Avito API specifications are stored in the `docs/` directory as Swagger/OpenAPI files. This is the authoritative source of truth for all API contracts. -- Использовать абсолютные импорты внутри пакета. -- Избегать циклических зависимостей между пакетами API-разделов. -- Зависимости должны быть минимально необходимыми. -- Если стандартная библиотека решает задачу без потери качества, сторонняя библиотека не нужна. +Rules: -## Чего в проекте быть не должно +- Before implementing any new method or model, consult the specification in `docs/`. +- The SDK must cover **all** API methods described in `docs/`. A method absent from the SDK but present in the specification is a defect. +- Public method signatures, model field names and types, allowed enum values, and nullable behavior must exactly match the contract in `docs/`. +- When there is a discrepancy between code and the specification in `docs/`, the specification takes priority. +- If the upstream API adds a new endpoint or changes an existing one, a corresponding SDK change is mandatory. +- Fields marked as required in the specification cannot be `T | None` in the public model without explicit justification. +- Enum values in the SDK must match the allowed values from the specification — arbitrary extension is forbidden. -- Глобального состояния токена без контролируемого владельца. -- Методов, возвращающих неструктурированный JSON в основном API. -- Смешения transport, auth, parsing и domain logic в одном классе. -- Неаннотированных публичных методов. -- Широкого использования `Any`. -- Обработки ошибок через `assert`. -- Скрытых сетевых побочных эффектов в свойствах и dataclass. -- Утечки transport-layer shapes и mapper-деталей в публичные сигнатуры и модели. -- Неявного или недокументированного config resolution через environment. -- Абстрактных имён полей (`resource_id`) там, где предметное имя известно и однозначно. -- Динамического добавления методов к классам через `setattr`, патчинг через `globals()` и иную рантайм-магию. -- Двух публичных методов, делающих одно и то же, без явной пометки одного из них как deprecated. -- Псевдонимов типов без explicit deprecation. -- Аннотации `list[T]` там, где в рантайме возвращается `PaginatedList[T]`. -- `AuthenticationError` как подкласса `AuthorizationError`: 401 и 403 — разные ошибки. -- Сообщений об ошибках на смешанных языках: весь user-facing текст ошибок должен быть на одном языке. -- Обобщённых env-алиасов (`SECRET`, `TOKEN`) в официальном config contract. -- Мёртвого кода: неиспользуемых символов, псевдонимов и импортов. -- Request-объектов внутреннего слоя в публичных сигнатурах доменных методов. +## Imports and Dependencies + +Rules: + +- Use absolute imports within the package. +- Avoid circular dependencies between API section packages. +- Dependencies must be minimal. +- If the standard library solves the problem without loss of quality, a third-party library is not needed. + +## What Must Not Exist in the Project + +- Global token state without a controlled owner. +- Methods returning unstructured JSON in the main API. +- Mixing of transport, auth, parsing, and domain logic in one class. +- Unannotated public methods. +- Widespread use of `Any`. +- Error handling via `assert`. +- Hidden network side effects in properties and dataclasses. +- Leakage of transport-layer shapes and mapper details into public signatures and models. +- Implicit or undocumented config resolution through the environment. +- Abstract field names (`resource_id`) where a domain-specific name is known and unambiguous. +- Dynamic method injection into classes via `setattr`, patching via `globals()`, or other runtime magic. +- Two public methods doing the same thing without one of them being explicitly marked as deprecated. +- Type aliases without explicit deprecation. +- `list[T]` annotation where `PaginatedList[T]` is returned at runtime. +- `AuthenticationError` as a subclass of `AuthorizationError`: 401 and 403 are different errors. +- Error messages in mixed languages: all user-facing error text must be in one language. +- Generic environment aliases (`SECRET`, `TOKEN`) in the official config contract. +- Dead code: unused symbols, aliases, and imports. +- Internal-layer request objects in public domain method signatures. diff --git a/avito/_env.py b/avito/_env.py index 7d3e461..dc05e37 100644 --- a/avito/_env.py +++ b/avito/_env.py @@ -4,8 +4,11 @@ import os from collections.abc import Mapping +from json import JSONDecodeError, loads from pathlib import Path +from avito.core.exceptions import ConfigurationError + def read_dotenv(env_file: str | Path | None) -> dict[str, str]: """Читает простой `.env` файл без побочных эффектов.""" @@ -61,3 +64,64 @@ def _first_present(source: Mapping[str, str], aliases: tuple[str, ...]) -> str | if value is not None and value != "": return value return None + + +def parse_env_int(value: str, *, field_name: str) -> int: + """Преобразует env-значение в `int` с typed-ошибкой.""" + + try: + return int(value) + except ValueError as exc: + raise ConfigurationError( + f"Некорректное значение `{field_name}`: ожидается int, получено {value!r}." + ) from exc + + +def parse_env_float(value: str, *, field_name: str) -> float: + """Преобразует env-значение в `float` с typed-ошибкой.""" + + try: + return float(value) + except ValueError as exc: + raise ConfigurationError( + f"Некорректное значение `{field_name}`: ожидается float, получено {value!r}." + ) from exc + + +def parse_env_bool(value: str, *, field_name: str) -> bool: + """Преобразует env-значение в `bool` с typed-ошибкой.""" + + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise ConfigurationError( + f"Некорректное значение `{field_name}`: ожидается bool, получено {value!r}." + ) + + +def parse_env_str_tuple(value: str, *, field_name: str) -> tuple[str, ...]: + """Преобразует env-значение в кортеж строк.""" + + stripped = value.strip() + if not stripped: + return () + if stripped.startswith("["): + try: + parsed = loads(stripped) + except JSONDecodeError as exc: + raise ConfigurationError( + f"Некорректное значение `{field_name}`: ожидается JSON-массив строк." + ) from exc + if not isinstance(parsed, list) or not all(isinstance(item, str) for item in parsed): + raise ConfigurationError( + f"Некорректное значение `{field_name}`: ожидается список строк." + ) + return tuple(parsed) + parts = tuple(part.strip() for part in stripped.split(",") if part.strip()) + if not parts: + raise ConfigurationError( + f"Некорректное значение `{field_name}`: ожидается непустой список строк." + ) + return parts diff --git a/avito/accounts/domain.py b/avito/accounts/domain.py index 8aac08d..74abbc4 100644 --- a/avito/accounts/domain.py +++ b/avito/accounts/domain.py @@ -4,6 +4,7 @@ from collections.abc import Sequence from dataclasses import dataclass +from datetime import datetime from avito.accounts.client import AccountsClient, HierarchyClient from avito.accounts.models import ( @@ -23,6 +24,10 @@ from avito.core.domain import DomainObject +def _serialize_datetime(value: datetime | None) -> str | None: + return value.isoformat() if value is not None else None + + @dataclass(slots=True, frozen=True) class Account(DomainObject): """Доменный объект операций аккаунта.""" @@ -45,8 +50,8 @@ def get_balance(self, user_id: int | None = None) -> AccountBalance: def get_operations_history( self, *, - date_from: str | None = None, - date_to: str | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, limit: int | None = None, offset: int | None = None, ) -> PaginatedList[OperationRecord]: @@ -54,8 +59,8 @@ def get_operations_history( return AccountsClient(self.transport).get_operations_history( OperationsHistoryRequest( - date_from=date_from, - date_to=date_to, + date_from=_serialize_datetime(date_from), + date_to=_serialize_datetime(date_to), limit=limit, offset=offset, ) diff --git a/avito/accounts/mappers.py b/avito/accounts/mappers.py index 4891702..2d9ad72 100644 --- a/avito/accounts/mappers.py +++ b/avito/accounts/mappers.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from datetime import datetime from typing import cast from avito.accounts.models import ( @@ -46,6 +47,18 @@ def _as_str(payload: Payload, *keys: str) -> str | None: return None +def _as_datetime(payload: Payload, *keys: str) -> datetime | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str): + normalized = value.replace("Z", "+00:00") + try: + return datetime.fromisoformat(normalized) + except ValueError: + continue + return None + + def _as_int(payload: Payload, *keys: str) -> int | None: for key in keys: value = payload.get(key) @@ -114,7 +127,7 @@ def map_operations_history(payload: object) -> OperationsHistoryResult: operations = [ OperationRecord( id=_as_str(item, "id", "operation_id"), - created_at=_as_str(item, "created_at", "createdAt", "date"), + created_at=_as_datetime(item, "created_at", "createdAt", "date"), amount=_as_float(item, "amount", "price", "sum"), operation_type=_as_str(item, "type", "operation_type", "operationType"), status=_as_str(item, "status"), diff --git a/avito/accounts/models.py b/avito/accounts/models.py index 3dd4f9f..8bf83e3 100644 --- a/avito/accounts/models.py +++ b/avito/accounts/models.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime from avito.core.serialization import SerializableModel @@ -33,7 +34,7 @@ class OperationRecord(SerializableModel): """Операция по аккаунту.""" id: str | None - created_at: str | None + created_at: datetime | None amount: float | None operation_type: str | None status: str | None @@ -203,4 +204,3 @@ class AccountActionResult(SerializableModel): "OperationsHistoryRequest", "OperationsHistoryResult", ) - diff --git a/avito/ads/domain.py b/avito/ads/domain.py index 0911bf1..260ba2f 100644 --- a/avito/ads/domain.py +++ b/avito/ads/domain.py @@ -4,6 +4,7 @@ from collections.abc import Sequence from dataclasses import dataclass +from datetime import datetime from avito.ads.client import ( AdsClient, @@ -66,6 +67,10 @@ def _preview_result( ) +def _serialize_datetime(value: datetime | None) -> str | None: + return value.isoformat() if value is not None else None + + @dataclass(slots=True, frozen=True) class Ad(DomainObject): """Доменный объект объявления.""" @@ -119,8 +124,8 @@ def get_calls_stats( self, *, item_ids: list[int] | None = None, - date_from: str | None = None, - date_to: str | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, ) -> CallsStatsResult: """Получает статистику звонков.""" @@ -131,7 +136,9 @@ def get_calls_stats( return StatsClient(self.transport).get_calls_stats( user_id=user_id, request=CallsStatsRequest( - item_ids=resolved_item_ids, date_from=date_from, date_to=date_to + item_ids=resolved_item_ids, + date_from=_serialize_datetime(date_from), + date_to=_serialize_datetime(date_to), ), ) @@ -139,8 +146,8 @@ def get_item_stats( self, *, item_ids: list[int] | None = None, - date_from: str | None = None, - date_to: str | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, fields: list[str] | None = None, ) -> ItemStatsResult: """Получает статистику по списку объявлений.""" @@ -153,8 +160,8 @@ def get_item_stats( user_id=user_id, request=ItemStatsRequest( item_ids=resolved_item_ids, - date_from=date_from, - date_to=date_to, + date_from=_serialize_datetime(date_from), + date_to=_serialize_datetime(date_to), fields=fields or [], ), ) @@ -163,8 +170,8 @@ def get_item_analytics( self, *, item_ids: list[int] | None = None, - date_from: str | None = None, - date_to: str | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, fields: list[str] | None = None, ) -> ItemAnalyticsResult: """Получает аналитику по профилю.""" @@ -177,8 +184,8 @@ def get_item_analytics( user_id=user_id, request=ItemStatsRequest( item_ids=resolved_item_ids, - date_from=date_from, - date_to=date_to, + date_from=_serialize_datetime(date_from), + date_to=_serialize_datetime(date_to), fields=fields or [], ), ) @@ -187,8 +194,8 @@ def get_account_spendings( self, *, item_ids: list[int] | None = None, - date_from: str | None = None, - date_to: str | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, fields: list[str] | None = None, ) -> AccountSpendings: """Получает статистику расходов профиля.""" @@ -201,8 +208,8 @@ def get_account_spendings( user_id=user_id, request=ItemStatsRequest( item_ids=resolved_item_ids, - date_from=date_from, - date_to=date_to, + date_from=_serialize_datetime(date_from), + date_to=_serialize_datetime(date_to), fields=fields or [], ), ) diff --git a/avito/ads/mappers.py b/avito/ads/mappers.py index 694630d..9cf9967 100644 --- a/avito/ads/mappers.py +++ b/avito/ads/mappers.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from datetime import datetime from typing import cast from avito.ads.models import ( @@ -63,6 +64,18 @@ def _str(payload: Payload, *keys: str) -> str | None: return None +def _datetime(payload: Payload, *keys: str) -> datetime | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str): + normalized = value.replace("Z", "+00:00") + try: + return datetime.fromisoformat(normalized) + except ValueError: + continue + return None + + def _int(payload: Payload, *keys: str) -> int | None: for key in keys: value = payload.get(key) @@ -280,8 +293,8 @@ def _map_report_summary(item: Payload) -> AutoloadReportSummary: return AutoloadReportSummary( report_id=_int(item, "report_id", "reportId", "id"), status=_str(item, "status"), - created_at=_str(item, "created_at", "createdAt"), - finished_at=_str(item, "finished_at", "finishedAt"), + created_at=_datetime(item, "created_at", "createdAt"), + finished_at=_datetime(item, "finished_at", "finishedAt"), processed_items=_int(item, "processed_items", "processedItems", "items"), ) @@ -303,8 +316,8 @@ def map_autoload_report_details(payload: object) -> AutoloadReportDetails: return AutoloadReportDetails( report_id=_int(data, "report_id", "reportId", "id"), status=_str(data, "status"), - created_at=_str(data, "created_at", "createdAt"), - finished_at=_str(data, "finished_at", "finishedAt"), + created_at=_datetime(data, "created_at", "createdAt"), + finished_at=_datetime(data, "finished_at", "finishedAt"), errors_count=_int(data, "errors_count", "errorsCount"), warnings_count=_int(data, "warnings_count", "warningsCount"), ) diff --git a/avito/ads/models.py b/avito/ads/models.py index 84540fe..57fa979 100644 --- a/avito/ads/models.py +++ b/avito/ads/models.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from datetime import datetime from avito.core.serialization import SerializableModel @@ -320,8 +321,8 @@ class AutoloadReportSummary(SerializableModel): report_id: int | None status: str | None - created_at: str | None - finished_at: str | None + created_at: datetime | None + finished_at: datetime | None processed_items: int | None @@ -374,8 +375,8 @@ class AutoloadReportDetails(SerializableModel): report_id: int | None status: str | None - created_at: str | None - finished_at: str | None + created_at: datetime | None + finished_at: datetime | None errors_count: int | None warnings_count: int | None diff --git a/avito/auth/settings.py b/avito/auth/settings.py index 5f8027f..6d25f51 100644 --- a/avito/auth/settings.py +++ b/avito/auth/settings.py @@ -2,16 +2,16 @@ from __future__ import annotations +from dataclasses import dataclass from pathlib import Path from typing import ClassVar -from pydantic import AliasChoices, BaseModel, ConfigDict, Field - from avito._env import resolve_env_aliases from avito.core.exceptions import ConfigurationError -class AuthSettings(BaseModel): +@dataclass(slots=True, frozen=True) +class AuthSettings: """Единственный публичный контракт OAuth-конфигурации SDK.""" ENV_ALIASES: ClassVar[dict[str, tuple[str, ...]]] = { @@ -48,71 +48,16 @@ class AuthSettings(BaseModel): ), } - model_config = ConfigDict( - extra="ignore", - populate_by_name=True, - ) - - client_id: str | None = Field( - default=None, - validation_alias=AliasChoices("AVITO_AUTH__CLIENT_ID", "AVITO_CLIENT_ID"), - ) - client_secret: str | None = Field( - default=None, - validation_alias=AliasChoices( - "AVITO_AUTH__CLIENT_SECRET", - "AVITO_CLIENT_SECRET", - ), - ) - scope: str | None = Field( - default=None, - validation_alias=AliasChoices("AVITO_AUTH__SCOPE", "AVITO_SCOPE"), - ) - refresh_token: str | None = Field( - default=None, - validation_alias=AliasChoices( - "AVITO_AUTH__REFRESH_TOKEN", "AVITO_REFRESH_TOKEN" - ), - ) - token_url: str = Field( - default="/token", - validation_alias=AliasChoices("AVITO_AUTH__TOKEN_URL", "AVITO_TOKEN_URL"), - ) - alternate_token_url: str = Field( - default="/token", - validation_alias=AliasChoices( - "AVITO_AUTH__ALTERNATE_TOKEN_URL", - "AVITO_ALTERNATE_TOKEN_URL", - ), - ) - autoteka_token_url: str = Field( - default="/autoteka/token", - validation_alias=AliasChoices( - "AVITO_AUTH__AUTOTEKA_TOKEN_URL", - "AVITO_AUTOTEKA_TOKEN_URL", - ), - ) - autoteka_client_id: str | None = Field( - default=None, - validation_alias=AliasChoices( - "AVITO_AUTH__AUTOTEKA_CLIENT_ID", - "AVITO_AUTOTEKA_CLIENT_ID", - ), - ) - autoteka_client_secret: str | None = Field( - default=None, - validation_alias=AliasChoices( - "AVITO_AUTH__AUTOTEKA_CLIENT_SECRET", - "AVITO_AUTOTEKA_CLIENT_SECRET", - ), - ) - autoteka_scope: str | None = Field( - default=None, - validation_alias=AliasChoices( - "AVITO_AUTH__AUTOTEKA_SCOPE", - "AVITO_AUTOTEKA_SCOPE", - ), - ) + client_id: str | None = None + client_secret: str | None = None + scope: str | None = None + refresh_token: str | None = None + token_url: str = "/token" + alternate_token_url: str = "/token" + autoteka_token_url: str = "/autoteka/token" + autoteka_client_id: str | None = None + autoteka_client_secret: str | None = None + autoteka_scope: str | None = None @classmethod def from_env(cls, *, env_file: str | Path | None = ".env") -> AuthSettings: diff --git a/avito/client/client.py b/avito/client.py similarity index 92% rename from avito/client/client.py rename to avito/client.py index 0deb889..7f041d5 100644 --- a/avito/client/client.py +++ b/avito/client.py @@ -184,9 +184,15 @@ def chat_webhook(self) -> ChatWebhook: return ChatWebhook(self.transport) - def chat_media(self, *, user_id: int | str | None = None) -> ChatMedia: + def chat_media( + self, + media_id: int | str | None = None, + *, + user_id: int | str | None = None, + ) -> ChatMedia: """Создает доменный объект медиа мессенджера.""" + _ = media_id return ChatMedia(self.transport, user_id=user_id) def special_offer_campaign(self, campaign_id: int | str | None = None) -> SpecialOfferCampaign: @@ -224,9 +230,10 @@ def autostrategy_campaign(self, campaign_id: int | str | None = None) -> Autostr return AutostrategyCampaign(self.transport, campaign_id=campaign_id) - def order(self) -> Order: + def order(self, order_id: int | str | None = None) -> Order: """Создает доменный объект заказа.""" + _ = order_id return Order(self.transport) def order_label(self, task_id: int | str | None = None) -> OrderLabel: @@ -234,14 +241,16 @@ def order_label(self, task_id: int | str | None = None) -> OrderLabel: return OrderLabel(self.transport, task_id=task_id) - def delivery_order(self) -> DeliveryOrder: + def delivery_order(self, order_id: int | str | None = None) -> DeliveryOrder: """Создает доменный объект доставки.""" + _ = order_id return DeliveryOrder(self.transport) - def sandbox_delivery(self) -> SandboxDelivery: + def sandbox_delivery(self, sandbox_id: int | str | None = None) -> SandboxDelivery: """Создает доменный объект песочницы доставки.""" + _ = sandbox_id return SandboxDelivery(self.transport) def delivery_task(self, task_id: int | str | None = None) -> DeliveryTask: @@ -249,9 +258,10 @@ def delivery_task(self, task_id: int | str | None = None) -> DeliveryTask: return DeliveryTask(self.transport, task_id=task_id) - def stock(self) -> Stock: + def stock(self, stock_id: int | str | None = None) -> Stock: """Создает доменный объект остатков.""" + _ = stock_id return Stock(self.transport) def vacancy(self, vacancy_id: int | str | None = None) -> Vacancy: @@ -259,9 +269,10 @@ def vacancy(self, vacancy_id: int | str | None = None) -> Vacancy: return Vacancy(self.transport, vacancy_id=vacancy_id) - def application(self) -> Application: + def application(self, application_id: int | str | None = None) -> Application: """Создает доменный объект отклика.""" + _ = application_id return Application(self.transport) def resume(self, resume_id: int | str | None = None) -> Resume: @@ -279,9 +290,10 @@ def job_dictionary(self, dictionary_id: int | str | None = None) -> JobDictionar return JobDictionary(self.transport, dictionary_id=dictionary_id) - def cpa_lead(self) -> CpaLead: + def cpa_lead(self, lead_id: int | str | None = None) -> CpaLead: """Создает доменный объект CPA-лида.""" + _ = lead_id return CpaLead(self.transport) def cpa_chat(self, chat_id: int | str | None = None) -> CpaChat: @@ -289,9 +301,10 @@ def cpa_chat(self, chat_id: int | str | None = None) -> CpaChat: return CpaChat(self.transport, action_id=chat_id) - def cpa_call(self) -> CpaCall: + def cpa_call(self, call_id: int | str | None = None) -> CpaCall: """Создает доменный объект CPA-звонка.""" + _ = call_id return CpaCall(self.transport) def cpa_archive(self, call_id: int | str | None = None) -> CpaArchive: @@ -314,9 +327,10 @@ def autoteka_report(self, report_id: int | str | None = None) -> AutotekaReport: return AutotekaReport(self.transport, report_id=report_id) - def autoteka_monitoring(self) -> AutotekaMonitoring: + def autoteka_monitoring(self, monitoring_id: int | str | None = None) -> AutotekaMonitoring: """Создает доменный объект мониторинга Автотеки.""" + _ = monitoring_id return AutotekaMonitoring(self.transport) def autoteka_scoring(self, scoring_id: int | str | None = None) -> AutotekaScoring: @@ -324,9 +338,10 @@ def autoteka_scoring(self, scoring_id: int | str | None = None) -> AutotekaScori return AutotekaScoring(self.transport, scoring_id=scoring_id) - def autoteka_valuation(self) -> AutotekaValuation: + def autoteka_valuation(self, valuation_id: int | str | None = None) -> AutotekaValuation: """Создает доменный объект оценки Автотеки.""" + _ = valuation_id return AutotekaValuation(self.transport) def realty_listing( @@ -369,9 +384,10 @@ def realty_analytics_report( return RealtyAnalyticsReport(self.transport, item_id=item_id, user_id=user_id) - def review(self) -> Review: + def review(self, review_id: int | str | None = None) -> Review: """Создает доменный объект отзыва.""" + _ = review_id return Review(self.transport) def review_answer(self, answer_id: int | str | None = None) -> ReviewAnswer: @@ -379,9 +395,10 @@ def review_answer(self, answer_id: int | str | None = None) -> ReviewAnswer: return ReviewAnswer(self.transport, answer_id=answer_id) - def rating_profile(self) -> RatingProfile: + def rating_profile(self, profile_id: int | str | None = None) -> RatingProfile: """Создает доменный объект рейтингового профиля.""" + _ = profile_id return RatingProfile(self.transport) def tariff(self, tariff_id: int | str | None = None) -> Tariff: diff --git a/avito/client/__init__.py b/avito/client/__init__.py deleted file mode 100644 index be13f2c..0000000 --- a/avito/client/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Публичные экспорты клиентского пакета.""" - -from avito.client.client import AvitoClient - -__all__ = ("AvitoClient",) diff --git a/avito/config.py b/avito/config.py index 72a1e9a..59089d2 100644 --- a/avito/config.py +++ b/avito/config.py @@ -2,26 +2,18 @@ from __future__ import annotations +from dataclasses import dataclass, field from pathlib import Path from typing import ClassVar -from pydantic import AliasChoices, BaseModel, ConfigDict, Field - -from avito._env import resolve_env_aliases +from avito._env import parse_env_int, resolve_env_aliases from avito.auth.settings import AuthSettings from avito.core.retries import RetryPolicy from avito.core.types import ApiTimeouts -def _default_timeouts() -> ApiTimeouts: - return ApiTimeouts(_env_file=None) # type: ignore[call-arg] - - -def _default_retry_policy() -> RetryPolicy: - return RetryPolicy(_env_file=None) # type: ignore[call-arg] - - -class AvitoSettings(BaseModel): +@dataclass(slots=True, frozen=True) +class AvitoSettings: """Единственный публичный контракт конфигурации SDK.""" ENV_ALIASES: ClassVar[dict[str, tuple[str, ...]]] = { @@ -29,22 +21,11 @@ class AvitoSettings(BaseModel): "user_id": ("AVITO_USER_ID",), } - model_config = ConfigDict( - extra="ignore", - populate_by_name=True, - ) - - base_url: str = Field( - default="https://api.avito.ru", - validation_alias=AliasChoices("AVITO_BASE_URL"), - ) - user_id: int | None = Field( - default=None, - validation_alias=AliasChoices("AVITO_USER_ID"), - ) - auth: AuthSettings = Field(default_factory=AuthSettings) - timeouts: ApiTimeouts = Field(default_factory=_default_timeouts) - retry_policy: RetryPolicy = Field(default_factory=_default_retry_policy) + base_url: str = "https://api.avito.ru" + user_id: int | None = None + auth: AuthSettings = field(default_factory=AuthSettings) + timeouts: ApiTimeouts = field(default_factory=ApiTimeouts) + retry_policy: RetryPolicy = field(default_factory=RetryPolicy) @property def client_id(self) -> str | None: @@ -63,14 +44,14 @@ def from_env(cls, *, env_file: str | Path | None = ".env") -> AvitoSettings: """Загружает конфигурацию из окружения и optional `.env` файла.""" resolved_values = resolve_env_aliases(cls.ENV_ALIASES, env_file=env_file) + user_id = resolved_values.get("user_id") auth_settings = AuthSettings.from_env(env_file=env_file) - return cls.model_validate( - { - **resolved_values, - "auth": auth_settings, - "timeouts": ApiTimeouts(_env_file=env_file), # type: ignore[call-arg] - "retry_policy": RetryPolicy(_env_file=env_file), # type: ignore[call-arg] - } + return cls( + base_url=resolved_values.get("base_url", "https://api.avito.ru"), + user_id=parse_env_int(user_id, field_name="user_id") if user_id is not None else None, + auth=auth_settings, + timeouts=ApiTimeouts.from_env(env_file=env_file), + retry_policy=RetryPolicy.from_env(env_file=env_file), ).validate_required() @classmethod diff --git a/avito/core/retries.py b/avito/core/retries.py index 96d47c6..76c8d64 100644 --- a/avito/core/retries.py +++ b/avito/core/retries.py @@ -3,33 +3,62 @@ from __future__ import annotations from dataclasses import dataclass +from pathlib import Path +from typing import ClassVar from typing import Literal -from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict +from avito._env import ( + parse_env_bool, + parse_env_float, + parse_env_int, + parse_env_str_tuple, + resolve_env_aliases, +) RetryReason = Literal[ "transport_error", "timeout", "rate_limit", "server_error", "unauthorized_refresh" ] -class RetryPolicy(BaseSettings): +@dataclass(slots=True, frozen=True) +class RetryPolicy: """Конфигурация повторных попыток для transport-слоя.""" - model_config = SettingsConfigDict( - env_prefix="AVITO_RETRY_", - env_file=".env", - extra="ignore", - ) + ENV_ALIASES: ClassVar[dict[str, tuple[str, ...]]] = { + "max_attempts": ("AVITO_RETRY_MAX_ATTEMPTS",), + "backoff_factor": ("AVITO_RETRY_BACKOFF_FACTOR",), + "retryable_methods": ("AVITO_RETRY_RETRYABLE_METHODS",), + "retry_on_rate_limit": ("AVITO_RETRY_RETRY_ON_RATE_LIMIT",), + "retry_on_server_error": ("AVITO_RETRY_RETRY_ON_SERVER_ERROR",), + "retry_on_transport_error": ("AVITO_RETRY_RETRY_ON_TRANSPORT_ERROR",), + "max_rate_limit_wait_seconds": ("AVITO_RETRY_MAX_RATE_LIMIT_WAIT_SECONDS",), + } max_attempts: int = 3 backoff_factor: float = 0.5 - retryable_methods: tuple[str, ...] = Field(default=("GET", "HEAD", "OPTIONS", "PUT", "DELETE")) + retryable_methods: tuple[str, ...] = ("GET", "HEAD", "OPTIONS", "PUT", "DELETE") retry_on_rate_limit: bool = True retry_on_server_error: bool = True retry_on_transport_error: bool = True max_rate_limit_wait_seconds: float = 30.0 + @classmethod + def from_env(cls, *, env_file: str | Path | None = ".env") -> RetryPolicy: + """Загружает retry-политику из process environment и optional `.env` файла.""" + + resolved_values = resolve_env_aliases(cls.ENV_ALIASES, env_file=env_file) + parsed_values: dict[str, object] = {} + for field_name, value in resolved_values.items(): + if field_name == "max_attempts": + parsed_values[field_name] = parse_env_int(value, field_name=field_name) + elif field_name in {"backoff_factor", "max_rate_limit_wait_seconds"}: + parsed_values[field_name] = parse_env_float(value, field_name=field_name) + elif field_name == "retryable_methods": + parsed_values[field_name] = parse_env_str_tuple(value, field_name=field_name) + else: + parsed_values[field_name] = parse_env_bool(value, field_name=field_name) + return cls(**parsed_values) + def is_retryable_method(self, method: str, *, explicit_retry: bool = False) -> bool: """Определяет, можно ли повторять запрос указанного HTTP-метода.""" diff --git a/avito/core/serialization.py b/avito/core/serialization.py index c4a54c9..15856ee 100644 --- a/avito/core/serialization.py +++ b/avito/core/serialization.py @@ -5,6 +5,7 @@ from base64 import b64encode from collections.abc import Mapping, Sequence from dataclasses import fields, is_dataclass +from datetime import date, datetime def _is_public_field(name: str) -> bool: @@ -14,6 +15,8 @@ def _is_public_field(name: str) -> bool: def _serialize_value(value: object) -> object: if isinstance(value, SerializableModel): return value.to_dict() + if isinstance(value, datetime | date): + return value.isoformat() if isinstance(value, bytes | bytearray): return b64encode(bytes(value)).decode("ascii") if is_dataclass(value): diff --git a/avito/core/types.py b/avito/core/types.py index 3a678da..d5a7a0b 100644 --- a/avito/core/types.py +++ b/avito/core/types.py @@ -4,27 +4,42 @@ from collections.abc import Mapping from dataclasses import dataclass, field +from pathlib import Path +from typing import ClassVar from typing import Literal -from pydantic_settings import BaseSettings, SettingsConfigDict +from avito._env import parse_env_float, resolve_env_aliases HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] -class ApiTimeouts(BaseSettings): +@dataclass(slots=True, frozen=True) +class ApiTimeouts: """Явные таймауты для HTTP-запросов SDK.""" - model_config = SettingsConfigDict( - env_prefix="AVITO_TIMEOUT_", - env_file=".env", - extra="ignore", - ) + ENV_ALIASES: ClassVar[dict[str, tuple[str, ...]]] = { + "connect": ("AVITO_TIMEOUT_CONNECT",), + "read": ("AVITO_TIMEOUT_READ",), + "write": ("AVITO_TIMEOUT_WRITE",), + "pool": ("AVITO_TIMEOUT_POOL",), + } connect: float = 5.0 read: float = 15.0 write: float = 15.0 pool: float = 5.0 + @classmethod + def from_env(cls, *, env_file: str | Path | None = ".env") -> ApiTimeouts: + """Загружает таймауты из process environment и optional `.env` файла.""" + + resolved_values = resolve_env_aliases(cls.ENV_ALIASES, env_file=env_file) + parsed_values: dict[str, float] = { + field_name: parse_env_float(value, field_name=field_name) + for field_name, value in resolved_values.items() + } + return cls(**parsed_values) + @dataclass(slots=True, frozen=True) class RequestContext: diff --git a/avito/messenger/mappers.py b/avito/messenger/mappers.py index fb3a4a4..b8ad350 100644 --- a/avito/messenger/mappers.py +++ b/avito/messenger/mappers.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from datetime import datetime from typing import cast from avito.core.exceptions import ResponseMappingError @@ -51,6 +52,18 @@ def _str(payload: Payload, *keys: str) -> str | None: return None +def _datetime(payload: Payload, *keys: str) -> datetime | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str): + normalized = value.replace("Z", "+00:00") + try: + return datetime.fromisoformat(normalized) + except ValueError: + continue + return None + + def _int(payload: Payload, *keys: str) -> int | None: for key in keys: value = payload.get(key) @@ -113,7 +126,7 @@ def map_message(payload: object) -> MessageInfo: chat_id=_str(data, "chat_id", "chatId"), author_id=_int(data, "author_id", "authorId", "user_id", "userId"), text=_str(data, "text", "message"), - created_at=_str(data, "created_at", "createdAt"), + created_at=_datetime(data, "created_at", "createdAt"), direction=_str(data, "direction"), type=_str(data, "type"), ) diff --git a/avito/messenger/models.py b/avito/messenger/models.py index eac952e..2464512 100644 --- a/avito/messenger/models.py +++ b/avito/messenger/models.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime from typing import BinaryIO from avito.core.serialization import SerializableModel @@ -69,7 +70,7 @@ class MessageInfo(SerializableModel): chat_id: str | None author_id: int | None text: str | None - created_at: str | None + created_at: datetime | None direction: str | None type: str | None diff --git a/avito/promotion/domain.py b/avito/promotion/domain.py index 6a5a6da..0a5c4d9 100644 --- a/avito/promotion/domain.py +++ b/avito/promotion/domain.py @@ -514,18 +514,34 @@ def list( limit: int = 100, offset: int | None = None, status_id: list[int] | None = None, - order_by: list[CampaignOrderBy] | None = None, - filter: CampaignListFilter | None = None, + order_by: list[tuple[str, str]] | None = None, + updated_from: datetime | None = None, + updated_to: datetime | None = None, ) -> CampaignsResult: """Получает список кампаний.""" + 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 AutostrategyClient(self.transport).list_campaigns( ListAutostrategyCampaignsRequest( limit=limit, offset=offset, status_id=status_id, - order_by=order_by, - filter=filter, + order_by=order_by_payload, + filter=filter_payload, ) ) diff --git a/avito/promotion/mappers.py b/avito/promotion/mappers.py index 8b98463..574a58b 100644 --- a/avito/promotion/mappers.py +++ b/avito/promotion/mappers.py @@ -111,9 +111,9 @@ def _datetime(payload: Payload, *keys: str) -> datetime | None: value = payload.get(key) if isinstance(value, str): try: - return datetime.fromisoformat(value) + return datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError: - return None + continue return None @@ -382,7 +382,7 @@ def map_cpa_auction_bids(payload: object) -> CpaAuctionBidsResult: CpaAuctionItemBid( item_id=_int(item, "itemId", "itemID"), price_penny=_int(item, "pricePenny"), - expiration_time=_str(item, "expirationTime"), + expiration_time=_datetime(item, "expirationTime"), available_prices=[ CpaAuctionBidOption( price_penny=_int(option, "pricePenny"), diff --git a/avito/promotion/models.py b/avito/promotion/models.py index eccdad9..28fc8fe 100644 --- a/avito/promotion/models.py +++ b/avito/promotion/models.py @@ -399,7 +399,7 @@ class CpaAuctionItemBid(SerializableModel): item_id: int | None price_penny: int | None - expiration_time: str | None + expiration_time: datetime | None available_prices: list[CpaAuctionBidOption] @@ -930,4 +930,3 @@ class AutostrategyStatTotals(SerializableModel): calls: int | None views: int | None - diff --git a/tests/test_config.py b/tests/test_config.py index a05c9a7..77e7f97 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -198,3 +198,35 @@ def test_process_environment_overrides_dotenv_deterministically( assert settings.base_url == "https://from-env.avito.ru" assert settings.auth.client_id == "env-client-id" assert settings.auth.client_secret == "env-client-secret" + + +def test_avito_settings_from_env_parses_timeout_and_retry_values( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + clear_avito_env(monkeypatch) + env_file = write_env_file( + tmp_path / ".env", + "\n".join( + ( + "AVITO_AUTH__CLIENT_ID=client-id", + "AVITO_AUTH__CLIENT_SECRET=client-secret", + "AVITO_TIMEOUT_CONNECT=2.5", + "AVITO_TIMEOUT_READ=11", + "AVITO_RETRY_MAX_ATTEMPTS=4", + "AVITO_RETRY_BACKOFF_FACTOR=0.75", + "AVITO_RETRY_RETRYABLE_METHODS=GET,POST,PATCH", + "AVITO_RETRY_RETRY_ON_RATE_LIMIT=false", + "AVITO_RETRY_MAX_RATE_LIMIT_WAIT_SECONDS=12.5", + ) + ), + ) + + settings = AvitoSettings.from_env(env_file=env_file) + + assert settings.timeouts.connect == 2.5 + assert settings.timeouts.read == 11.0 + assert settings.retry_policy.max_attempts == 4 + assert settings.retry_policy.backoff_factor == 0.75 + assert settings.retry_policy.retryable_methods == ("GET", "POST", "PATCH") + assert settings.retry_policy.retry_on_rate_limit is False + assert settings.retry_policy.max_rate_limit_wait_seconds == 12.5 diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py index 979ccc0..d6c0a9d 100644 --- a/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -1,5 +1,7 @@ from __future__ import annotations +from datetime import datetime + from avito.autoteka.models import CatalogResolveRequest, MonitoringBucketRequest from avito.promotion.models import ( CampaignListFilter, @@ -13,8 +15,8 @@ def test_autostrategy_request_models_produce_correct_payload() -> None: budget_request = CreateAutostrategyBudgetRequest( campaign_type="AS", - start_time="2026-04-20T00:00:00Z", - finish_time="2026-04-27T00:00:00Z", + start_time=datetime.fromisoformat("2026-04-20T00:00:00+00:00"), + finish_time=datetime.fromisoformat("2026-04-27T00:00:00+00:00"), items=[42, 43], ) campaigns_request = ListAutostrategyCampaignsRequest( @@ -23,16 +25,16 @@ def test_autostrategy_request_models_produce_correct_payload() -> None: order_by=[CampaignOrderBy(column="startTime", direction="asc")], filter=CampaignListFilter( by_update_time=CampaignUpdateTimeFilter( - from_time="2026-04-01T00:00:00Z", - to_time="2026-04-30T00:00:00Z", + from_time=datetime.fromisoformat("2026-04-01T00:00:00+00:00"), + to_time=datetime.fromisoformat("2026-04-30T00:00:00+00:00"), ) ), ) assert budget_request.to_payload() == { "campaignType": "AS", - "startTime": "2026-04-20T00:00:00Z", - "finishTime": "2026-04-27T00:00:00Z", + "startTime": "2026-04-20T00:00:00+00:00", + "finishTime": "2026-04-27T00:00:00+00:00", "items": [42, 43], } assert campaigns_request.to_payload() == { @@ -41,8 +43,8 @@ def test_autostrategy_request_models_produce_correct_payload() -> None: "orderBy": [{"column": "startTime", "direction": "asc"}], "filter": { "byUpdateTime": { - "from": "2026-04-01T00:00:00Z", - "to": "2026-04-30T00:00:00Z", + "from": "2026-04-01T00:00:00+00:00", + "to": "2026-04-30T00:00:00+00:00", } }, } diff --git a/tests/test_stage4_datetime_domain_inputs.py b/tests/test_stage4_datetime_domain_inputs.py new file mode 100644 index 0000000..2405a52 --- /dev/null +++ b/tests/test_stage4_datetime_domain_inputs.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import json +from datetime import datetime + +import httpx + +from avito.accounts import Account +from avito.ads import AdStats +from avito.auth import AuthSettings +from avito.config import AvitoSettings +from avito.core import Transport +from avito.core.retries import RetryPolicy +from avito.core.types import ApiTimeouts + + +def make_transport(handler: httpx.MockTransport) -> Transport: + settings = AvitoSettings( + base_url="https://api.avito.ru", + auth=AuthSettings(), + retry_policy=RetryPolicy(), + timeouts=ApiTimeouts(), + ) + return Transport( + settings, + auth_provider=None, + client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), + sleep=lambda _: None, + ) + + +def test_account_operations_history_accepts_datetime_filters() -> None: + date_from = datetime(2025, 1, 1, 10, 30, 0) + date_to = datetime(2025, 1, 2, 18, 45, 0) + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/core/v1/accounts/operations_history/" + assert json.loads(request.content.decode()) == { + "dateFrom": "2025-01-01T10:30:00", + "dateTo": "2025-01-02T18:45:00", + "limit": 5, + "offset": 10, + } + return httpx.Response(200, json={"operations": [], "total": 0}) + + transport = make_transport(httpx.MockTransport(handler)) + + result = Account(transport, user_id=7).get_operations_history( + date_from=date_from, + date_to=date_to, + limit=5, + offset=10, + ) + + assert result == [] + + +def test_ad_stats_accept_datetime_filters_and_serialize_isoformat() -> None: + date_from = datetime(2025, 1, 1, 0, 0, 0) + date_to = datetime(2025, 1, 31, 23, 59, 59) + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content.decode()) + assert body["itemIds"] == [101] + assert body["dateFrom"] == "2025-01-01T00:00:00" + assert body["dateTo"] == "2025-01-31T23:59:59" + + if request.url.path == "/core/v1/accounts/7/calls/stats/": + return httpx.Response(200, json={"items": []}) + if request.url.path == "/stats/v1/accounts/7/items": + return httpx.Response(200, json={"items": []}) + if request.url.path == "/stats/v2/accounts/7/items": + return httpx.Response(200, json={"items": [], "period": "month"}) + assert request.url.path == "/stats/v2/accounts/7/spendings" + return httpx.Response(200, json={"items": [], "total": 0}) + + transport = make_transport(httpx.MockTransport(handler)) + stats = AdStats(transport, item_id=101, user_id=7) + + calls = stats.get_calls_stats(date_from=date_from, date_to=date_to) + item_stats = stats.get_item_stats(date_from=date_from, date_to=date_to) + analytics = stats.get_item_analytics(date_from=date_from, date_to=date_to) + spendings = stats.get_account_spendings(date_from=date_from, date_to=date_to) + + assert calls.items == [] + assert item_stats.items == [] + assert analytics.period == "month" + assert spendings.total == 0 diff --git a/tests/test_stage5_datetime_model_mapping.py b/tests/test_stage5_datetime_model_mapping.py new file mode 100644 index 0000000..136c562 --- /dev/null +++ b/tests/test_stage5_datetime_model_mapping.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime + +from avito.accounts.mappers import map_operations_history +from avito.ads.mappers import map_autoload_report_details, map_autoload_reports +from avito.messenger.mappers import map_message +from avito.promotion.mappers import map_cpa_auction_bids + + +def test_datetime_fields_are_parsed_into_models_and_serialized_as_iso_strings() -> None: + operations = map_operations_history( + { + "operations": [ + { + "id": "op-1", + "created_at": "2025-01-02T12:00:00Z", + "amount": 120.0, + "type": "payment", + "status": "done", + } + ], + "total": 1, + } + ) + message = map_message( + { + "id": "msg-1", + "chat_id": "chat-1", + "author_id": 7, + "text": "hello", + "created_at": "2025-01-03T08:15:00+03:00", + } + ) + reports = map_autoload_reports( + { + "reports": [ + { + "report_id": 501, + "status": "done", + "created_at": "2025-01-04T09:00:00+03:00", + "finished_at": "2025-01-04T09:10:00+03:00", + } + ] + } + ) + details = map_autoload_report_details( + { + "report_id": 501, + "status": "done", + "created_at": "2025-01-04T09:00:00+03:00", + "finished_at": "2025-01-04T09:10:00+03:00", + "errors_count": 0, + "warnings_count": 1, + } + ) + bids = map_cpa_auction_bids( + { + "items": [ + { + "itemId": 101, + "pricePenny": 1500, + "expirationTime": "2025-01-05T18:30:00Z", + "availablePrices": [{"pricePenny": 1600, "goodness": 9}], + } + ] + } + ) + + assert operations.operations[0].created_at == datetime(2025, 1, 2, 12, 0, tzinfo=UTC) + assert message.created_at == datetime.fromisoformat("2025-01-03T08:15:00+03:00") + assert reports.items[0].created_at == datetime.fromisoformat("2025-01-04T09:00:00+03:00") + assert reports.items[0].finished_at == datetime.fromisoformat("2025-01-04T09:10:00+03:00") + assert details.created_at == datetime.fromisoformat("2025-01-04T09:00:00+03:00") + assert details.finished_at == datetime.fromisoformat("2025-01-04T09:10:00+03:00") + assert bids.items[0].expiration_time == datetime(2025, 1, 5, 18, 30, tzinfo=UTC) + + assert operations.to_dict()["operations"][0]["created_at"] == "2025-01-02T12:00:00+00:00" + assert message.to_dict()["created_at"] == "2025-01-03T08:15:00+03:00" + assert reports.to_dict()["items"][0]["created_at"] == "2025-01-04T09:00:00+03:00" + assert reports.to_dict()["items"][0]["finished_at"] == "2025-01-04T09:10:00+03:00" + assert details.to_dict()["created_at"] == "2025-01-04T09:00:00+03:00" + assert details.to_dict()["finished_at"] == "2025-01-04T09:10:00+03:00" + assert bids.to_dict()["items"][0]["expiration_time"] == "2025-01-05T18:30:00+00:00" + + json.dumps(operations.to_dict()) + json.dumps(message.to_dict()) + json.dumps(reports.to_dict()) + json.dumps(details.to_dict()) + json.dumps(bids.to_dict()) diff --git a/tests/test_stage6_autostrategy_list_signature.py b/tests/test_stage6_autostrategy_list_signature.py new file mode 100644 index 0000000..fd485dd --- /dev/null +++ b/tests/test_stage6_autostrategy_list_signature.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import json +from datetime import datetime + +import httpx + +from avito.auth import AuthSettings +from avito.config import AvitoSettings +from avito.core import Transport +from avito.core.retries import RetryPolicy +from avito.core.types import ApiTimeouts +from avito.promotion import AutostrategyCampaign + + +def make_transport(handler: httpx.MockTransport) -> Transport: + settings = AvitoSettings( + base_url="https://api.avito.ru", + auth=AuthSettings(), + retry_policy=RetryPolicy(), + timeouts=ApiTimeouts(), + ) + return Transport( + settings, + auth_provider=None, + client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), + sleep=lambda _: None, + ) + + +def test_autostrategy_list_accepts_keyword_fields_without_public_dto() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/autostrategy/v1/campaigns" + assert json.loads(request.content.decode()) == { + "limit": 20, + "offset": 10, + "statusId": [1, 2], + "orderBy": [{"column": "startTime", "direction": "asc"}], + "filter": { + "byUpdateTime": { + "from": "2026-04-01T00:00:00", + "to": "2026-04-30T00:00:00", + } + }, + } + return httpx.Response( + 200, + json={ + "campaigns": [ + { + "campaignId": 77, + "campaignType": "AS", + "title": "Весенняя кампания", + "statusId": 1, + "version": 3, + } + ], + "totalCount": 1, + }, + ) + + transport = make_transport(httpx.MockTransport(handler)) + campaigns = AutostrategyCampaign(transport).list( + limit=20, + offset=10, + status_id=[1, 2], + order_by=[("startTime", "asc")], + updated_from=datetime(2026, 4, 1, 0, 0, 0), + updated_to=datetime(2026, 4, 30, 0, 0, 0), + ) + + assert campaigns.total_count == 1 + assert campaigns.items[0].campaign_id == 77 From 52df5abe24692ea5e0b690f8492834b2f8cb47bc Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Mon, 20 Apr 2026 23:43:04 +0300 Subject: [PATCH 13/17] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D0=B3=D0=B0=D0=B9=D0=B4=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- avito/core/exceptions.py | 20 ++++++++++++++++++-- tests/test_config.py | 21 +++++++++++++++++++++ tests/test_stage6_error_model.py | 17 +++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/avito/core/exceptions.py b/avito/core/exceptions.py index 4162819..d225bf5 100644 --- a/avito/core/exceptions.py +++ b/avito/core/exceptions.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass, field +from dataclasses import FrozenInstanceError, dataclass, field _SECRET_KEYS = ( "authorization", @@ -40,7 +40,7 @@ def sanitize_metadata(value: object) -> object: return value -@dataclass(slots=True, frozen=True) +@dataclass(slots=True) class AvitoError(Exception): """Базовое исключение SDK с безопасными диагностическими метаданными.""" @@ -51,6 +51,21 @@ class AvitoError(Exception): metadata: Mapping[str, object] = field(default_factory=dict) payload: object | None = None headers: Mapping[str, str] | None = None + _is_initialized: bool = field(init=False, default=False, repr=False, compare=False) + + def __setattr__(self, name: str, value: object) -> None: + if name == "_is_initialized": + object.__setattr__(self, name, value) + return + + if not getattr(self, "_is_initialized", False): + object.__setattr__(self, name, value) + return + + if name in self.__dataclass_fields__: + raise FrozenInstanceError(f"cannot assign to field {name!r}") + + object.__setattr__(self, name, value) def __post_init__(self) -> None: sanitized_payload = sanitize_metadata(self.payload) @@ -61,6 +76,7 @@ def __post_init__(self) -> None: object.__setattr__(self, "payload", sanitized_payload) object.__setattr__(self, "headers", sanitized_headers) object.__setattr__(self, "metadata", sanitized_metadata) + object.__setattr__(self, "_is_initialized", True) def __str__(self) -> str: details: list[str] = [self.message] diff --git a/tests/test_config.py b/tests/test_config.py index 77e7f97..fe9c5d0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -106,6 +106,27 @@ def test_avito_settings_from_env_requires_client_secret( AvitoSettings.from_env(env_file=env_file) +def test_avito_settings_from_env_ignores_unsupported_generic_aliases( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + clear_avito_env(monkeypatch) + env_file = write_env_file( + tmp_path / ".env", + "\n".join( + ( + "BASE_URL=https://generic.avito.ru", + "USER_ID=77", + "CLIENT_ID=generic-client-id", + "SECRET=generic-client-secret", + "AVITO_SECRET=legacy-secret", + ) + ), + ) + + with pytest.raises(ConfigurationError, match="client_id"): + AvitoSettings.from_env(env_file=env_file) + + def test_avito_client_from_env_initializes_client( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/tests/test_stage6_error_model.py b/tests/test_stage6_error_model.py index 3968370..1dd11ab 100644 --- a/tests/test_stage6_error_model.py +++ b/tests/test_stage6_error_model.py @@ -1,11 +1,14 @@ from __future__ import annotations +from dataclasses import FrozenInstanceError + import httpx import pytest from avito.auth import AuthSettings from avito.config import AvitoSettings from avito.core import ( + AvitoError, AuthorizationError, ConflictError, RateLimitError, @@ -118,3 +121,17 @@ def test_authorization_error_is_raised_for_auth_failures() -> None: transport.request_json("GET", "/secure", context=RequestContext("accounts.get_self")) assert error.value.operation == "accounts.get_self" + + +def test_avito_error_is_frozen_dataclass() -> None: + error = AvitoError( + "boom", + payload={"access_token": "secret-token"}, + headers={"Authorization": "Bearer secret-token"}, + ) + + with pytest.raises(FrozenInstanceError): + error.message = "updated" + + assert error.payload == {"access_token": "***"} + assert error.headers == {"Authorization": "***"} From 1a7e4a8478c16327fd164ef3322fa9b026f3c9f9 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Mon, 20 Apr 2026 23:55:48 +0300 Subject: [PATCH 14/17] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D0=B3=D0=B0=D0=B9=D0=B4=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 5 +- avito/client.py | 41 ++++-------- avito/core/exceptions.py | 2 +- avito/cpa/models.py | 9 ++- avito/orders/models.py | 5 +- avito/promotion/models.py | 7 +- tests/test_facade.py | 24 +++---- todo.md | 126 ------------------------------------ 8 files changed, 36 insertions(+), 183 deletions(-) delete mode 100644 todo.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f6326b8..3c49d85 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,10 @@ "Bash(.venv/bin/ruff check *)", "Bash(.venv/bin/ruff format *)", "Bash(.venv/bin/pytest tests/ -q)", - "Bash(python *)" + "Bash(python *)", + "Bash(git stash *)", + "Bash(poetry run *)", + "Bash(make lint *)" ] } } diff --git a/avito/client.py b/avito/client.py index 7f041d5..0deb889 100644 --- a/avito/client.py +++ b/avito/client.py @@ -184,15 +184,9 @@ def chat_webhook(self) -> ChatWebhook: return ChatWebhook(self.transport) - def chat_media( - self, - media_id: int | str | None = None, - *, - user_id: int | str | None = None, - ) -> ChatMedia: + def chat_media(self, *, user_id: int | str | None = None) -> ChatMedia: """Создает доменный объект медиа мессенджера.""" - _ = media_id return ChatMedia(self.transport, user_id=user_id) def special_offer_campaign(self, campaign_id: int | str | None = None) -> SpecialOfferCampaign: @@ -230,10 +224,9 @@ def autostrategy_campaign(self, campaign_id: int | str | None = None) -> Autostr return AutostrategyCampaign(self.transport, campaign_id=campaign_id) - def order(self, order_id: int | str | None = None) -> Order: + def order(self) -> Order: """Создает доменный объект заказа.""" - _ = order_id return Order(self.transport) def order_label(self, task_id: int | str | None = None) -> OrderLabel: @@ -241,16 +234,14 @@ def order_label(self, task_id: int | str | None = None) -> OrderLabel: return OrderLabel(self.transport, task_id=task_id) - def delivery_order(self, order_id: int | str | None = None) -> DeliveryOrder: + def delivery_order(self) -> DeliveryOrder: """Создает доменный объект доставки.""" - _ = order_id return DeliveryOrder(self.transport) - def sandbox_delivery(self, sandbox_id: int | str | None = None) -> SandboxDelivery: + def sandbox_delivery(self) -> SandboxDelivery: """Создает доменный объект песочницы доставки.""" - _ = sandbox_id return SandboxDelivery(self.transport) def delivery_task(self, task_id: int | str | None = None) -> DeliveryTask: @@ -258,10 +249,9 @@ def delivery_task(self, task_id: int | str | None = None) -> DeliveryTask: return DeliveryTask(self.transport, task_id=task_id) - def stock(self, stock_id: int | str | None = None) -> Stock: + def stock(self) -> Stock: """Создает доменный объект остатков.""" - _ = stock_id return Stock(self.transport) def vacancy(self, vacancy_id: int | str | None = None) -> Vacancy: @@ -269,10 +259,9 @@ def vacancy(self, vacancy_id: int | str | None = None) -> Vacancy: return Vacancy(self.transport, vacancy_id=vacancy_id) - def application(self, application_id: int | str | None = None) -> Application: + def application(self) -> Application: """Создает доменный объект отклика.""" - _ = application_id return Application(self.transport) def resume(self, resume_id: int | str | None = None) -> Resume: @@ -290,10 +279,9 @@ def job_dictionary(self, dictionary_id: int | str | None = None) -> JobDictionar return JobDictionary(self.transport, dictionary_id=dictionary_id) - def cpa_lead(self, lead_id: int | str | None = None) -> CpaLead: + def cpa_lead(self) -> CpaLead: """Создает доменный объект CPA-лида.""" - _ = lead_id return CpaLead(self.transport) def cpa_chat(self, chat_id: int | str | None = None) -> CpaChat: @@ -301,10 +289,9 @@ def cpa_chat(self, chat_id: int | str | None = None) -> CpaChat: return CpaChat(self.transport, action_id=chat_id) - def cpa_call(self, call_id: int | str | None = None) -> CpaCall: + def cpa_call(self) -> CpaCall: """Создает доменный объект CPA-звонка.""" - _ = call_id return CpaCall(self.transport) def cpa_archive(self, call_id: int | str | None = None) -> CpaArchive: @@ -327,10 +314,9 @@ def autoteka_report(self, report_id: int | str | None = None) -> AutotekaReport: return AutotekaReport(self.transport, report_id=report_id) - def autoteka_monitoring(self, monitoring_id: int | str | None = None) -> AutotekaMonitoring: + def autoteka_monitoring(self) -> AutotekaMonitoring: """Создает доменный объект мониторинга Автотеки.""" - _ = monitoring_id return AutotekaMonitoring(self.transport) def autoteka_scoring(self, scoring_id: int | str | None = None) -> AutotekaScoring: @@ -338,10 +324,9 @@ def autoteka_scoring(self, scoring_id: int | str | None = None) -> AutotekaScori return AutotekaScoring(self.transport, scoring_id=scoring_id) - def autoteka_valuation(self, valuation_id: int | str | None = None) -> AutotekaValuation: + def autoteka_valuation(self) -> AutotekaValuation: """Создает доменный объект оценки Автотеки.""" - _ = valuation_id return AutotekaValuation(self.transport) def realty_listing( @@ -384,10 +369,9 @@ def realty_analytics_report( return RealtyAnalyticsReport(self.transport, item_id=item_id, user_id=user_id) - def review(self, review_id: int | str | None = None) -> Review: + def review(self) -> Review: """Создает доменный объект отзыва.""" - _ = review_id return Review(self.transport) def review_answer(self, answer_id: int | str | None = None) -> ReviewAnswer: @@ -395,10 +379,9 @@ def review_answer(self, answer_id: int | str | None = None) -> ReviewAnswer: return ReviewAnswer(self.transport, answer_id=answer_id) - def rating_profile(self, profile_id: int | str | None = None) -> RatingProfile: + def rating_profile(self) -> RatingProfile: """Создает доменный объект рейтингового профиля.""" - _ = profile_id return RatingProfile(self.transport) def tariff(self, tariff_id: int | str | None = None) -> Tariff: diff --git a/avito/core/exceptions.py b/avito/core/exceptions.py index d225bf5..4eacd81 100644 --- a/avito/core/exceptions.py +++ b/avito/core/exceptions.py @@ -63,7 +63,7 @@ def __setattr__(self, name: str, value: object) -> None: return if name in self.__dataclass_fields__: - raise FrozenInstanceError(f"cannot assign to field {name!r}") + raise FrozenInstanceError(f"нельзя присвоить значение полю {name!r}") object.__setattr__(self, name, value) diff --git a/avito/cpa/models.py b/avito/cpa/models.py index 91dd84f..930f577 100644 --- a/avito/cpa/models.py +++ b/avito/cpa/models.py @@ -4,7 +4,6 @@ from base64 import b64encode from dataclasses import dataclass -from typing import Any from avito.core import BinaryResponse from avito.core.serialization import SerializableModel @@ -209,7 +208,7 @@ def filename(self) -> str | None: return self.binary.filename - def to_dict(self) -> dict[str, Any]: + def to_dict(self) -> dict[str, object]: """Сериализует бинарную запись без transport-объекта.""" return { @@ -218,7 +217,7 @@ def to_dict(self) -> dict[str, Any]: "content_base64": b64encode(self.binary.content).decode("ascii"), } - def model_dump(self) -> dict[str, Any]: + def model_dump(self) -> dict[str, object]: return self.to_dict() @@ -276,7 +275,7 @@ def filename(self) -> str | None: return self.binary.filename - def to_dict(self) -> dict[str, Any]: + def to_dict(self) -> dict[str, object]: """Сериализует бинарную запись без transport-объекта.""" return { @@ -285,7 +284,7 @@ def to_dict(self) -> dict[str, Any]: "content_base64": b64encode(self.binary.content).decode("ascii"), } - def model_dump(self) -> dict[str, Any]: + def model_dump(self) -> dict[str, object]: return self.to_dict() diff --git a/avito/orders/models.py b/avito/orders/models.py index f7cb664..c9cad74 100644 --- a/avito/orders/models.py +++ b/avito/orders/models.py @@ -4,7 +4,6 @@ from base64 import b64encode from dataclasses import dataclass -from typing import Any from avito.core import BinaryResponse from avito.core.serialization import SerializableModel @@ -1083,7 +1082,7 @@ def filename(self) -> str | None: return self.binary.filename - def to_dict(self) -> dict[str, Any]: + def to_dict(self) -> dict[str, object]: """Сериализует бинарный результат без transport-объекта.""" return { @@ -1092,7 +1091,7 @@ def to_dict(self) -> dict[str, Any]: "content_base64": b64encode(self.binary.content).decode("ascii"), } - def model_dump(self) -> dict[str, Any]: + def model_dump(self) -> dict[str, object]: return self.to_dict() diff --git a/avito/promotion/models.py b/avito/promotion/models.py index 28fc8fe..127b6f8 100644 --- a/avito/promotion/models.py +++ b/avito/promotion/models.py @@ -5,7 +5,6 @@ from dataclasses import dataclass, field from datetime import datetime from typing import TypeAlias, TypedDict -import warnings from avito.core.serialization import SerializableModel @@ -214,11 +213,7 @@ class BbipForecastsResult(SerializableModel): items: list[BbipForecast] -warnings.warn( - "PromotionForecast устарел и будет удалён. Используйте BbipForecast.", - DeprecationWarning, - stacklevel=2, -) +# deprecated: используйте BbipForecast напрямую PromotionForecast: TypeAlias = BbipForecast diff --git a/tests/test_facade.py b/tests/test_facade.py index 12589e0..5446619 100644 --- a/tests/test_facade.py +++ b/tests/test_facade.py @@ -45,7 +45,7 @@ def test_single_client_exposes_domain_factories() -> None: assert isinstance(client.chat("chat-1", user_id=1), Chat) assert isinstance(client.chat_message("msg-1", chat_id="chat-1", user_id=1), ChatMessage) assert isinstance(client.chat_webhook(), ChatWebhook) - assert isinstance(client.chat_media("media-1", user_id=1), ChatMedia) + assert isinstance(client.chat_media(user_id=1), ChatMedia) assert isinstance(client.special_offer_campaign(1), SpecialOfferCampaign) assert isinstance(client.promotion_order(1), PromotionOrder) assert isinstance(client.bbip_promotion(1), BbipPromotion) @@ -53,34 +53,34 @@ def test_single_client_exposes_domain_factories() -> None: assert isinstance(client.cpa_auction(1), CpaAuction) assert isinstance(client.target_action_pricing(1), TargetActionPricing) assert isinstance(client.autostrategy_campaign(1), AutostrategyCampaign) - assert isinstance(client.order(1), Order) + assert isinstance(client.order(), Order) assert isinstance(client.order_label(1), OrderLabel) - assert isinstance(client.delivery_order(1), DeliveryOrder) - assert isinstance(client.sandbox_delivery(1), SandboxDelivery) + assert isinstance(client.delivery_order(), DeliveryOrder) + assert isinstance(client.sandbox_delivery(), SandboxDelivery) assert isinstance(client.delivery_task(1), DeliveryTask) - assert isinstance(client.stock(1), Stock) + assert isinstance(client.stock(), Stock) assert isinstance(client.vacancy(1), Vacancy) - assert isinstance(client.application(1), Application) + assert isinstance(client.application(), Application) assert isinstance(client.resume(1), Resume) assert isinstance(client.job_webhook(), JobWebhook) assert isinstance(client.job_dictionary(1), JobDictionary) - assert isinstance(client.cpa_lead(1), CpaLead) + assert isinstance(client.cpa_lead(), CpaLead) assert isinstance(client.cpa_chat(1), CpaChat) - assert isinstance(client.cpa_call(1), CpaCall) + assert isinstance(client.cpa_call(), CpaCall) assert isinstance(client.cpa_archive(1), CpaArchive) assert isinstance(client.call_tracking_call(1), CallTrackingCall) assert isinstance(client.autoteka_vehicle(1), AutotekaVehicle) assert isinstance(client.autoteka_report(1), AutotekaReport) - assert isinstance(client.autoteka_monitoring(1), AutotekaMonitoring) + assert isinstance(client.autoteka_monitoring(), AutotekaMonitoring) assert isinstance(client.autoteka_scoring(1), AutotekaScoring) - assert isinstance(client.autoteka_valuation(1), AutotekaValuation) + assert isinstance(client.autoteka_valuation(), AutotekaValuation) assert isinstance(client.realty_listing(1), RealtyListing) assert isinstance(client.realty_booking(1), RealtyBooking) assert isinstance(client.realty_pricing(1), RealtyPricing) assert isinstance(client.realty_analytics_report(1), RealtyAnalyticsReport) - assert isinstance(client.review(1), Review) + assert isinstance(client.review(), Review) assert isinstance(client.review_answer(1), ReviewAnswer) - assert isinstance(client.rating_profile(1), RatingProfile) + assert isinstance(client.rating_profile(), RatingProfile) assert isinstance(client.tariff(1), Tariff) diff --git a/todo.md b/todo.md deleted file mode 100644 index e30d54a..0000000 --- a/todo.md +++ /dev/null @@ -1,126 +0,0 @@ -# TODO: Устранение несоответствий STYLEGUIDE - -Анализ всех Python-файлов проекта против STYLEGUIDE.md. 12 несоответствий, упорядочены по приоритету. - ---- - -## Найденные несоответствия - -### 1. `PromotionForecast: TypeAlias = BbipForecast` без deprecation-метки -**Файл**: `avito/promotion/models.py:211` -**Правило**: «Псевдонимы типов без явной deprecation-метки запрещены». -**Действие**: Добавить `warnings.warn(DeprecationWarning)` при определении алиаса. - ---- - -### 2. Конфигурационные классы на `pydantic.BaseModel` / `BaseSettings` -**Файлы**: `avito/config.py`, `avito/auth/settings.py`, `avito/core/types.py`, `avito/core/retries.py` -**Правило**: STYLEGUIDE показывает `AvitoSettings`, `AuthSettings` как `@dataclass(slots=True, frozen=True)`. Pydantic допустим только для чтения env на границе системы — не как основа публичного SDK-объекта. -**Действие**: Переписать все четыре класса как `@dataclass(slots=True, frozen=True)` с ручным `from_env()` через уже существующий `_env.py`. - ---- - -### 3. `avito/client/` — пакет вместо файла `avito/client.py` -**Файлы**: `avito/client/__init__.py`, `avito/client/client.py` -**Правило**: Целевая архитектура STYLEGUIDE прямо указывает `avito/client.py` (файл, не пакет). -**Действие**: Перенести `avito/client/client.py` → `avito/client.py`, обновить все импорты, удалить папку. - ---- - -### 4. Даты как голый `str` без валидации формата -**Файлы**: `avito/ads/domain.py`, `avito/accounts/domain.py` -**Правило**: «Даты должны принимать `datetime` — голый `str` без проверки не допускается». -**Нарушение**: Параметры `date_from: str | None`, `date_to: str | None` в публичных методах `AdStats.*`, `Account.get_operations_history()`. -**Действие**: Изменить на `date_from: datetime | None`, преобразовывать в ISO 8601 перед передачей в transport. - ---- - -### 5. Date-поля в моделях как `str | None` вместо `datetime | None` -**Файлы**: -- `avito/accounts/models.py`: `OperationRecord.created_at` -- `avito/messenger/models.py`: `MessageInfo.created_at` -- `avito/ads/models.py`: `AutoloadReportSummary.created_at/finished_at`, `AutoloadReportDetails.created_at/finished_at` -- `avito/promotion/models.py`: `CpaAuctionItemBid.expiration_time` -**Действие**: Изменить поля на `datetime | None`, парсить в mapper-ах. - ---- - -### 6. Request-DTO в публичных сигнатурах domain-методов -**Файл**: `avito/promotion/domain.py:514-530` — `AutostrategyCampaign.list()` -**Правило**: «Request-DTO не должны появляться в публичных сигнатурах». -**Нарушение**: `filter: CampaignListFilter | None`, `order_by: list[CampaignOrderBy] | None`. -**Действие**: Раскрыть поля как keyword-only аргументы напрямую в `list()`. - ---- - -### 7. Неиспользуемые поля `user_id` (мёртвый код) -**Файлы**: -- `avito/ads/domain.py`: `AutoloadReport.user_id`, `AutoloadArchive.user_id` -- `avito/tariffs/domain.py`: `Tariff.user_id` -- `avito/promotion/domain.py`: `CpaAuction.user_id`, `PromotionOrder.user_id` -**Правило**: «Мёртвый код не допускается». -**Действие**: Удалить поля там, где они нигде не читаются. - ---- - -### 8. `TrxItemInput(TypedDict, total=False)` — неточная типизация -**Файл**: `avito/promotion/models.py:145` -**Нарушение**: `total=False` делает `item_id`, `commission`, `date_from` опциональными, хотя они обязательны. -**Действие**: Разделить на обязательную часть и `total=False`-блок только для `date_to`: -```python -class _TrxItemInputRequired(TypedDict): - item_id: int - commission: int - date_from: datetime - -class TrxItemInput(_TrxItemInputRequired, total=False): - date_to: datetime | None -``` - ---- - -### 9. `AvitoError` без `frozen=True` -**Файл**: `avito/core/exceptions.py:43` -**Нарушение**: `@dataclass(slots=True)` — несогласованно с остальными моделями SDK. -**Действие**: Добавить `frozen=True`. `__post_init__` уже использует `object.__setattr__` — совместимо. - ---- - -### 10. Тесты используют обобщённые env-алиасы `SECRET`, `BASE_URL`, `CLIENT_ID` -**Файл**: `tests/test_config.py:12-26`, `test_avito_settings_from_env_supports_alias_variables` -**Правило**: «Обобщённые имена вроде `SECRET` или `TOKEN` не должны быть официальными алиасами». -**Нарушение**: Тесты очищают `SECRET`, `CLIENT_ID`, `BASE_URL` и тестируют поведение, которого нет в `ENV_ALIASES`. -**Действие**: Удалить `BASE_URL`, `USER_ID`, `CLIENT_ID`, `SECRET`, `AVITO_SECRET` из `ENV_KEYS` и из тестовых `.env`-файлов. - ---- - -### 11. Пропущена пустая строка в `avito/core/serialization.py` -**Файл**: `avito/core/serialization.py:7-8` -**Действие**: Добавить пустую строку между `from dataclasses import fields, is_dataclass` и `def _is_public_field`. - ---- - -### 12. Отсутствуют docstring у публичных методов -**Файлы**: -- `avito/tariffs/domain.py:19` — `Tariff.get_tariff_info()` без docstring -- `avito/promotion/models.py` — `CampaignUpdateTimeFilter.to_payload()`, `CampaignListFilter.to_payload()`, `CampaignOrderBy.to_payload()` без docstring -**Действие**: Добавить однострочные docstring. - ---- - -## Порядок выполнения (по возрастанию сложности) - -| # | Файл(ы) | Сложность | -|---|---------|-----------| -| 11 | `avito/core/serialization.py` | Минимальная | -| 12 | `avito/tariffs/domain.py`, `avito/promotion/models.py` | Минимальная | -| 1 | `avito/promotion/models.py` — alias deprecation | Низкая | -| 9 | `avito/core/exceptions.py` — frozen | Низкая | -| 7 | Мёртвые `user_id` (5 файлов) | Низкая | -| 10 | `tests/test_config.py` — env aliases | Низкая | -| 8 | `avito/promotion/models.py` — TrxItemInput | Низкая | -| 6 | `avito/promotion/domain.py` — list() params | Средняя | -| 5 | Date-поля в 5 моделях | Средняя | -| 4 | Date-параметры в domain-методах | Средняя | -| 3 | `avito/client/` → `avito/client.py` | Средняя | -| 2 | Config: pydantic → dataclass (4 файла) | Высокая | From f50c0f71ef1f0a28247b0f65eac5c9a38fb70214 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Tue, 21 Apr 2026 01:01:27 +0300 Subject: [PATCH 15/17] =?UTF-8?q?=D0=A2=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_client_contracts.py} | 41 +- tests/contracts/test_model_contracts.py | 292 +++++++ .../test_public_surface.py} | 105 ++- .../test_authentication.py} | 34 +- .../test_configuration.py} | 84 +- .../{test_core.py => core/test_transport.py} | 190 +---- tests/domains/accounts/test_accounts.py | 99 +++ tests/domains/ads/test_ads.py | 108 +++ tests/domains/autoteka/test_autoteka.py | 121 +++ tests/domains/cpa/test_cpa.py | 86 ++ tests/domains/jobs/test_jobs.py | 117 +++ tests/domains/messenger/test_messenger.py | 78 ++ tests/domains/orders/test_orders.py | 104 +++ tests/domains/promotion/test_promotion.py | 194 +++++ tests/domains/ratings/test_ratings.py | 32 + tests/domains/realty/test_realty.py | 55 ++ tests/domains/tariffs/test_tariffs.py | 41 + tests/helpers/transport.py | 25 + tests/test_calltracking_contract_alignment.py | 100 --- tests/test_no_raw_payload_contract.py | 41 - tests/test_no_valueerror_in_public_surface.py | 20 - tests/test_promotion_contract_alignment.py | 272 ------- tests/test_public_models.py | 223 ------ tests/test_read_contract.py | 128 --- tests/test_readme_examples.py | 55 -- tests/test_realty_contract_alignment.py | 123 --- tests/test_stage10_autoteka.py | 383 --------- tests/test_stage11_mock_transport_suite.py | 496 ------------ tests/test_stage11_realty_ratings_tariffs.py | 231 ------ tests/test_stage12_release_gate.py | 57 -- tests/test_stage4_accounts_ads.py | 373 --------- tests/test_stage4_datetime_domain_inputs.py | 88 -- tests/test_stage4_promotion_write_contract.py | 337 -------- tests/test_stage5_datetime_model_mapping.py | 91 --- tests/test_stage5_messenger.py | 223 ------ tests/test_stage5_promotion_read_contract.py | 163 ---- ...test_stage6_autostrategy_list_signature.py | 73 -- tests/test_stage6_error_model.py | 137 ---- tests/test_stage6_promotion.py | 500 ------------ tests/test_stage7_orders.py | 751 ------------------ tests/test_stage8_jobs.py | 299 ------- tests/test_stage8_serialization_contract.py | 189 ----- tests/test_stage9_cpa.py | 323 -------- tests/test_stage9_transport_isolation.py | 231 ------ 44 files changed, 1509 insertions(+), 6204 deletions(-) rename tests/{test_facade.py => contracts/test_client_contracts.py} (76%) create mode 100644 tests/contracts/test_model_contracts.py rename tests/{test_public_api_shape.py => contracts/test_public_surface.py} (65%) rename tests/{test_auth.py => core/test_authentication.py} (87%) rename tests/{test_config.py => core/test_configuration.py} (74%) rename tests/{test_core.py => core/test_transport.py} (60%) create mode 100644 tests/domains/accounts/test_accounts.py create mode 100644 tests/domains/ads/test_ads.py create mode 100644 tests/domains/autoteka/test_autoteka.py create mode 100644 tests/domains/cpa/test_cpa.py create mode 100644 tests/domains/jobs/test_jobs.py create mode 100644 tests/domains/messenger/test_messenger.py create mode 100644 tests/domains/orders/test_orders.py create mode 100644 tests/domains/promotion/test_promotion.py create mode 100644 tests/domains/ratings/test_ratings.py create mode 100644 tests/domains/realty/test_realty.py create mode 100644 tests/domains/tariffs/test_tariffs.py create mode 100644 tests/helpers/transport.py delete mode 100644 tests/test_calltracking_contract_alignment.py delete mode 100644 tests/test_no_raw_payload_contract.py delete mode 100644 tests/test_no_valueerror_in_public_surface.py delete mode 100644 tests/test_promotion_contract_alignment.py delete mode 100644 tests/test_public_models.py delete mode 100644 tests/test_read_contract.py delete mode 100644 tests/test_readme_examples.py delete mode 100644 tests/test_realty_contract_alignment.py delete mode 100644 tests/test_stage10_autoteka.py delete mode 100644 tests/test_stage11_mock_transport_suite.py delete mode 100644 tests/test_stage11_realty_ratings_tariffs.py delete mode 100644 tests/test_stage12_release_gate.py delete mode 100644 tests/test_stage4_accounts_ads.py delete mode 100644 tests/test_stage4_datetime_domain_inputs.py delete mode 100644 tests/test_stage4_promotion_write_contract.py delete mode 100644 tests/test_stage5_datetime_model_mapping.py delete mode 100644 tests/test_stage5_messenger.py delete mode 100644 tests/test_stage5_promotion_read_contract.py delete mode 100644 tests/test_stage6_autostrategy_list_signature.py delete mode 100644 tests/test_stage6_error_model.py delete mode 100644 tests/test_stage6_promotion.py delete mode 100644 tests/test_stage7_orders.py delete mode 100644 tests/test_stage8_jobs.py delete mode 100644 tests/test_stage8_serialization_contract.py delete mode 100644 tests/test_stage9_cpa.py delete mode 100644 tests/test_stage9_transport_isolation.py diff --git a/tests/test_facade.py b/tests/contracts/test_client_contracts.py similarity index 76% rename from tests/test_facade.py rename to tests/contracts/test_client_contracts.py index 5446619..92cd25e 100644 --- a/tests/test_facade.py +++ b/tests/contracts/test_client_contracts.py @@ -1,9 +1,12 @@ +from __future__ import annotations + +import httpx import pytest from avito import AuthSettings, AvitoClient, AvitoSettings from avito.accounts import Account, AccountHierarchy from avito.ads import Ad, AdPromotion, AdStats, AutoloadArchive, AutoloadProfile, AutoloadReport -from avito.auth import AuthProvider +from avito.auth import AlternateTokenClient, AuthProvider, TokenClient from avito.autoteka import ( AutotekaMonitoring, AutotekaReport, @@ -23,9 +26,10 @@ TargetActionPricing, TrxPromotion, ) -from avito.ratings import RatingProfile, Review, ReviewAnswer from avito.realty import RealtyAnalyticsReport, RealtyBooking, RealtyListing, RealtyPricing +from avito.ratings import RatingProfile, Review, ReviewAnswer from avito.tariffs import Tariff +from avito.core import Transport def test_single_client_exposes_domain_factories() -> None: @@ -98,3 +102,36 @@ def test_removed_legacy_factory_names_are_absent() -> None: with pytest.raises(AttributeError): _ = client.cpa_legacy # type: ignore[attr-defined] + + +def test_debug_info_and_context_manager_do_not_leak_secrets() -> None: + transport_http_client = httpx.Client() + token_http_client = httpx.Client() + alternate_http_client = httpx.Client() + autoteka_http_client = httpx.Client() + + settings = AvitoSettings( + base_url="https://api.avito.ru", + auth=AuthSettings(client_id="client-id", client_secret="super-secret"), + ) + auth_provider = AuthProvider( + settings.auth, + token_client=TokenClient(settings.auth, client=token_http_client), + alternate_token_client=AlternateTokenClient(settings.auth, client=alternate_http_client), + autoteka_token_client=TokenClient(settings.auth, client=autoteka_http_client), + ) + client = AvitoClient(settings) + client.transport = Transport(settings, auth_provider=auth_provider, client=transport_http_client) + client.auth_provider = auth_provider + + info = client.debug_info() + assert info.requires_auth is True + assert "secret" not in repr(info).lower() + + with client as managed_client: + assert managed_client.debug_info().requires_auth is True + + assert transport_http_client.is_closed is True + assert token_http_client.is_closed is True + assert alternate_http_client.is_closed is True + assert autoteka_http_client.is_closed is True diff --git a/tests/contracts/test_model_contracts.py b/tests/contracts/test_model_contracts.py new file mode 100644 index 0000000..ed9bf8b --- /dev/null +++ b/tests/contracts/test_model_contracts.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +import importlib +import json +from dataclasses import is_dataclass +from datetime import datetime +from inspect import isclass + +import httpx + +from avito.accounts import Account +from avito.accounts.models import AccountProfile +from avito.ads import Ad, AdStats +from avito.ads.models import AccountSpendings, CallStats, Listing, ListingStats, SpendingRecord +from avito.autoteka.models import ( + CatalogField, + CatalogFieldValue, + CatalogResolveRequest, + CatalogResolveResult, + MonitoringBucketRequest, +) +from avito.core.serialization import SerializableModel +from avito.core.types import BinaryResponse +from avito.cpa.models import CallTrackingRecord, CpaAudioRecord +from avito.messenger.models import SendMessageRequest +from avito.orders.models import LabelPdfResult +from avito.promotion import BbipPromotion, PromotionOrder, PromotionService +from avito.promotion.models import ( + AutostrategyBudget, + AutostrategyStat, + AutostrategyStatItem, + AutostrategyStatTotals, + BbipItem, + CampaignActionResult, + CampaignListFilter, + CampaignOrderBy, + CampaignUpdateTimeFilter, + CampaignsResult, + CreateAutostrategyBudgetRequest, + ListAutostrategyCampaignsRequest, + PromotionForecast, + PromotionOrderInfo, +) +from avito.tariffs.models import TariffContractInfo, TariffInfo +from tests.helpers.transport import make_transport + +MODEL_MODULES = ( + "avito.accounts.models", + "avito.ads.models", + "avito.autoteka.models", + "avito.cpa.models", + "avito.jobs.models", + "avito.messenger.models", + "avito.orders.models", + "avito.promotion.models", + "avito.ratings.models", + "avito.realty.models", + "avito.tariffs.models", +) + + +def iter_model_classes() -> list[tuple[str, str, type[object]]]: + classes: list[tuple[str, str, type[object]]] = [] + for module_name in MODEL_MODULES: + module = importlib.import_module(module_name) + for name, value in vars(module).items(): + if not isclass(value) or getattr(value, "__module__", None) != module_name: + continue + if not is_dataclass(value): + continue + classes.append((module_name, name, value)) + return classes + + +def test_all_model_dataclasses_expose_standard_serialization_methods() -> None: + missing = [ + f"{module_name}:{name}" + for module_name, name, cls in iter_model_classes() + if issubclass(cls, SerializableModel) + and (not hasattr(cls, "to_dict") or not hasattr(cls, "model_dump")) + ] + + assert missing == [] + + +def test_recursive_serialization_is_json_compatible_and_hides_transport_fields() -> None: + tariff = TariffInfo( + current=TariffContractInfo( + level="Максимальный", + is_active=True, + start_time=1713427200, + close_time=None, + bonus=10, + price=1990, + original_price=2490, + packages_count=2, + ), + scheduled=None, + ) + catalog = CatalogResolveResult( + items=[ + CatalogField( + field_id="brand", + label="Марка", + data_type="integer", + values=[CatalogFieldValue(value_id="1", label="Audi")], + ) + ], + ) + assert tariff.to_dict()["current"]["level"] == "Максимальный" + assert catalog.model_dump()["items"][0]["field_id"] == "brand" + json.dumps(tariff.to_dict()) + json.dumps(catalog.to_dict()) + request = SendMessageRequest(message="hello") + assert request.message == "hello" + assert request.type is None + + +def test_examples_and_binary_models_produce_expected_payloads() -> None: + budget_request = CreateAutostrategyBudgetRequest( + campaign_type="AS", + start_time=datetime.fromisoformat("2026-04-20T00:00:00+00:00"), + finish_time=datetime.fromisoformat("2026-04-27T00:00:00+00:00"), + items=[42, 43], + ) + campaigns_request = ListAutostrategyCampaignsRequest( + limit=50, + status_id=[1, 2], + order_by=[CampaignOrderBy(column="startTime", direction="asc")], + filter=CampaignListFilter( + by_update_time=CampaignUpdateTimeFilter( + from_time=datetime.fromisoformat("2026-04-01T00:00:00+00:00"), + to_time=datetime.fromisoformat("2026-04-30T00:00:00+00:00"), + ) + ), + ) + assert budget_request.to_payload()["campaignType"] == "AS" + assert campaigns_request.to_payload()["filter"]["byUpdateTime"]["from"].startswith("2026-04-01") + assert CatalogResolveRequest(brand_id=1).to_payload() == {"brandId": 1} + assert MonitoringBucketRequest(vehicles=["VIN-1"]).to_payload() == {"vehicles": ["VIN-1"]} + + response = BinaryResponse( + content=b"\x00\x01payload", + content_type="application/octet-stream", + filename="artifact.bin", + status_code=200, + headers={"x-test": "1"}, + ) + expected = { + "filename": "artifact.bin", + "content_type": "application/octet-stream", + "content_base64": "AAFwYXlsb2Fk", + } + assert LabelPdfResult(binary=response).to_dict() == expected + assert CpaAudioRecord(binary=response).model_dump() == expected + assert CallTrackingRecord(binary=response).to_dict() == expected + + +def test_primary_sdk_models_serialize_without_transport_fields() -> None: + profile = AccountProfile(user_id=7, name="Иван", email=None, phone="+7999") + listing = Listing( + item_id=101, + user_id=7, + title="Смартфон", + description=None, + status="active", + price=1000.0, + url=None, + ) + stats = ListingStats(item_id=101, views=42, contacts=None, favorites=3) + calls = CallStats(item_id=101, calls=4, answered_calls=3, missed_calls=1) + spendings = AccountSpendings( + items=[SpendingRecord(item_id=101, amount=77.5, service="xl")], + total=77.5, + ) + service = PromotionService( + item_id=101, + service_code="x2", + service_name="X2", + price=9900, + status="available", + ) + order = PromotionOrderInfo( + order_id="ord-1", + item_id=101, + service_code="x2", + status="created", + created_at=None, + ) + forecast = PromotionForecast( + item_id=101, + min_views=10, + max_views=25, + total_price=7000, + total_old_price=None, + ) + + assert profile.to_dict()["user_id"] == 7 + assert listing.to_dict()["item_id"] == 101 + assert stats.model_dump()["views"] == 42 + assert calls.to_dict()["calls"] == 4 + assert spendings.to_dict()["total"] == 77.5 + assert service.to_dict()["service_code"] == "x2" + assert order.to_dict()["order_id"] == "ord-1" + assert forecast.to_dict()["max_views"] == 25 + + +def test_model_read_flows_return_stable_sdk_models() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/core/v1/accounts/self": + return httpx.Response(200, json={"id": 7, "name": "Иван"}) + if path == "/core/v1/accounts/7/items/101/": + return httpx.Response(200, json={"id": 101, "user_id": 7, "title": "Смартфон"}) + if path == "/stats/v1/accounts/7/items": + return httpx.Response( + 200, + json={"items": [{"item_id": 101, "views": 42, "contacts": 5, "favorites": 3}]}, + ) + if path == "/core/v1/accounts/7/calls/stats/": + return httpx.Response( + 200, + json={ + "items": [{"item_id": 101, "calls": 4, "answered_calls": 3, "missed_calls": 1}] + }, + ) + if path == "/stats/v2/accounts/7/spendings": + return httpx.Response( + 200, + json={"items": [{"item_id": 101, "amount": 77.5, "service": "xl"}]}, + ) + if path == "/promotion/v1/items/services/get": + return httpx.Response( + 200, + json={ + "items": [ + { + "itemId": 101, + "serviceCode": "x2", + "serviceName": "X2", + "price": 9900, + "status": "available", + } + ] + }, + ) + if path == "/promotion/v1/items/services/orders/get": + return httpx.Response( + 200, + json={"items": [{"orderId": "ord-1", "itemId": 101, "serviceCode": "x2"}]}, + ) + return httpx.Response( + 200, + json={"items": [{"itemId": 101, "min": 10, "max": 25, "totalPrice": 7000}]}, + ) + + transport = make_transport(httpx.MockTransport(handler)) + + profile = Account(transport, user_id=7).get_self() + listing = Ad(transport, item_id=101, user_id=7).get() + stats = AdStats(transport, item_id=101, user_id=7).get_item_stats() + calls = AdStats(transport, item_id=101, user_id=7).get_calls_stats() + spendings = AdStats(transport, item_id=101, user_id=7).get_account_spendings() + services = PromotionOrder(transport, order_id="ord-1").list_services(item_ids=[101]) + orders = PromotionOrder(transport, order_id="ord-1").list_orders(item_ids=[101]) + forecasts = BbipPromotion(transport, item_id=101).get_forecasts( + items=[BbipItem(item_id=101, duration=7, price=1000, old_price=1200).to_dict()] + ) + + assert isinstance(profile, AccountProfile) + assert isinstance(listing, Listing) + assert isinstance(stats.items[0], ListingStats) + assert isinstance(calls.items[0], CallStats) + assert isinstance(spendings, AccountSpendings) + assert isinstance(services.items[0], PromotionService) + assert isinstance(orders.items[0], PromotionOrderInfo) + assert forecasts.items[0].max_views == 25 + + +def test_autostrategy_models_serialize_correctly() -> None: + budget = AutostrategyBudget(calc_id=1, recommended=None, minimal=None, maximal=None, price_ranges=[]) + result = CampaignActionResult(campaign=None) + campaigns = CampaignsResult(items=[], total_count=0) + stat = AutostrategyStat( + items=[AutostrategyStatItem(date="2026-01-01", calls=5, views=10)], + totals=AutostrategyStatTotals(calls=5, views=10), + ) + + assert budget.to_dict()["calc_id"] == 1 + assert result.to_dict() == {"campaign": None} + assert campaigns.to_dict() == {"items": [], "total_count": 0} + assert stat.to_dict()["totals"] == {"calls": 5, "views": 10} diff --git a/tests/test_public_api_shape.py b/tests/contracts/test_public_surface.py similarity index 65% rename from tests/test_public_api_shape.py rename to tests/contracts/test_public_surface.py index 9e03ebb..0cbb416 100644 --- a/tests/test_public_api_shape.py +++ b/tests/contracts/test_public_surface.py @@ -2,6 +2,8 @@ import importlib import inspect +from dataclasses import fields, is_dataclass +from pathlib import Path import avito.autoteka as autoteka import avito.jobs as jobs @@ -19,6 +21,33 @@ from avito.orders import DeliveryOrder, Order, OrderLabel, SandboxDelivery, Stock from avito.realty import RealtyBooking, RealtyListing, RealtyPricing +MODEL_MODULES = ( + "avito.accounts.models", + "avito.ads.models", + "avito.autoteka.models", + "avito.cpa.models", + "avito.jobs.models", + "avito.messenger.models", + "avito.orders.models", + "avito.promotion.models", + "avito.ratings.models", + "avito.realty.models", + "avito.tariffs.models", +) + + +def iter_public_dataclasses() -> list[tuple[str, str, type[object]]]: + classes: list[tuple[str, str, type[object]]] = [] + for module_name in MODEL_MODULES: + module = importlib.import_module(module_name) + for name, value in vars(module).items(): + if not inspect.isclass(value) or getattr(value, "__module__", None) != module_name: + continue + if not is_dataclass(value): + continue + classes.append((module_name, name, value)) + return classes + def test_removed_generic_request_wrappers_are_not_exported() -> None: assert "RealtyRequest" not in realty.__all__ @@ -68,13 +97,7 @@ def test_public_signatures_use_typed_requests_instead_of_generic_wrappers() -> N assert token not in public_text -def test_chat_media_upload_images_no_longer_accepts_raw_dict() -> None: - signature_text = str(inspect.signature(ChatMedia.upload_images)) - assert "dict[str, object]" not in signature_text - assert "UploadImageFile" in signature_text - - -def test_public_domain_and_client_methods_avoid_raw_dict_mapping_object_signatures() -> None: +def test_public_surface_avoids_raw_dict_signatures_and_legacy_suffixes() -> None: module_names = ( "avito.accounts.domain", "avito.accounts.client", @@ -99,7 +122,9 @@ def test_public_domain_and_client_methods_avoid_raw_dict_mapping_object_signatur "avito.tariffs.domain", "avito.tariffs.client", ) - banned_tokens = ("Mapping[str, object]", "dict[str, object]", "object]") + banned_signature_tokens = ("Mapping[str, object]", "dict[str, object]", "object]") + banned_name_fragments = ("legacy_",) + banned_suffixes = ("_v1", "_v2") offenders: list[str] = [] for module_name in module_names: @@ -111,46 +136,40 @@ def test_public_domain_and_client_methods_avoid_raw_dict_mapping_object_signatur if method_name.startswith("_"): continue signature_text = str(inspect.signature(method)) - if any(token in signature_text for token in banned_tokens): - offenders.append(f"{module_name}.{cls.__name__}.{method_name}{signature_text}") + if any(token in signature_text for token in banned_signature_tokens): + offenders.append(f"{module_name}.{cls.__name__}.{method_name}") + if any(fragment in method_name for fragment in banned_name_fragments): + offenders.append(f"{module_name}.{cls.__name__}.{method_name}") + if method_name.endswith(banned_suffixes): + offenders.append(f"{module_name}.{cls.__name__}.{method_name}") assert offenders == [] -def test_public_surface_does_not_expose_legacy_or_version_suffixed_method_names() -> None: - module_names = ( - "avito.client.client", - "avito.auth.provider", - "avito.ads.domain", - "avito.autoteka.client", - "avito.autoteka.domain", - "avito.cpa.domain", - "avito.cpa.client", - "avito.jobs.domain", - "avito.jobs.client", - "avito.orders.domain", - "avito.orders.client", - "avito.ratings.domain", - "avito.realty.domain", - ) - banned_fragments = ("legacy_",) - banned_suffixes = ("_v1", "_v2") - banned_prefixes = ("get_catalogs_", "list_report_", "list_monitoring_", "delete_monitoring_") +def test_public_surface_does_not_raise_valueerror_in_domain_or_client_layers() -> None: + root = Path(__file__).resolve().parents[2] / "avito" offenders: list[str] = [] - for module_name in module_names: - module = importlib.import_module(module_name) - for _, cls in inspect.getmembers(module, inspect.isclass): - if cls.__module__ != module_name or cls.__name__.startswith("_"): - continue - for method_name, method in inspect.getmembers(cls, inspect.isfunction): - if method_name.startswith("_"): - continue - if ( - any(fragment in method_name for fragment in banned_fragments) - or method_name.endswith(banned_suffixes) - or method_name.startswith(banned_prefixes) - ): - offenders.append(f"{module_name}.{cls.__name__}.{method_name}{inspect.signature(method)}") + for path in root.glob("*/domain.py"): + if "raise ValueError" in path.read_text(encoding="utf-8"): + offenders.append(path.as_posix()) + for path in root.glob("*/client.py"): + if "raise ValueError" in path.read_text(encoding="utf-8"): + offenders.append(path.as_posix()) + + assert offenders == [] + + +def test_public_models_do_not_expose_raw_payload_fields() -> None: + offenders = [] + for module_name, name, cls in iter_public_dataclasses(): + if any(field.name in {"raw_payload", "_payload"} for field in fields(cls)): + offenders.append(f"{module_name}:{name}") assert offenders == [] + + +def test_chat_media_upload_images_no_longer_accepts_raw_dict() -> None: + signature_text = str(inspect.signature(ChatMedia.upload_images)) + assert "dict[str, object]" not in signature_text + assert "UploadImageFile" in signature_text diff --git a/tests/test_auth.py b/tests/core/test_authentication.py similarity index 87% rename from tests/test_auth.py rename to tests/core/test_authentication.py index 041f193..171fe89 100644 --- a/tests/test_auth.py +++ b/tests/core/test_authentication.py @@ -122,7 +122,7 @@ def test_token_client_maps_authentication_error() -> None: assert error.value.error_code == "invalid_client" -def test_alternate_token_flow_does_not_create_duplicate_public_client_api() -> None: +def test_client_auth_surface_exposes_current_token_flows_only() -> None: settings = AvitoSettings( base_url="https://api.avito.ru", auth=AuthSettings(client_id="client-id", client_secret="client-secret"), @@ -139,7 +139,7 @@ def test_alternate_token_flow_does_not_create_duplicate_public_client_api() -> N _ = client.legacy_auth # type: ignore[attr-defined] -def test_alternate_token_client_uses_same_public_contract_for_duplicate_token_path() -> None: +def test_alternate_token_client_uses_refresh_contract_on_duplicate_path() -> None: captured_paths: list[str] = [] def handler(request: httpx.Request) -> httpx.Response: @@ -170,27 +170,19 @@ def test_auth_provider_uses_separate_autoteka_credentials_for_autoteka_token() - def handler(request: httpx.Request) -> httpx.Response: captured_payloads.append(request.content.decode()) - return httpx.Response( - 200, - json={"access_token": "autoteka-access", "expires_in": 3600}, - ) - + return httpx.Response(200, json={"access_token": "autoteka-access", "expires_in": 3600}) + + settings = AuthSettings( + client_id="client-id", + client_secret="client-secret", + autoteka_client_id="autoteka-client-id", + autoteka_client_secret="autoteka-client-secret", + autoteka_scope="autoteka:read", + ) provider = AuthProvider( - AuthSettings( - client_id="client-id", - client_secret="client-secret", - autoteka_client_id="autoteka-client-id", - autoteka_client_secret="autoteka-client-secret", - autoteka_scope="autoteka:read", - ), + settings, autoteka_token_client=TokenClient( - AuthSettings( - client_id="client-id", - client_secret="client-secret", - autoteka_client_id="autoteka-client-id", - autoteka_client_secret="autoteka-client-secret", - autoteka_scope="autoteka:read", - ), + settings, client=make_token_http_client(httpx.MockTransport(handler)), ), ) diff --git a/tests/test_config.py b/tests/core/test_configuration.py similarity index 74% rename from tests/test_config.py rename to tests/core/test_configuration.py index fe9c5d0..49c7c31 100644 --- a/tests/test_config.py +++ b/tests/core/test_configuration.py @@ -80,30 +80,17 @@ def test_avito_settings_from_env_supports_alias_variables( assert settings.auth.client_secret == "file-client-secret" -def test_avito_settings_from_env_requires_client_id( +def test_avito_settings_from_env_requires_explicit_auth_values( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: clear_avito_env(monkeypatch) - env_file = write_env_file( - tmp_path / ".env", - "AVITO_AUTH__CLIENT_SECRET=client-secret\n", - ) with pytest.raises(ConfigurationError, match="client_id"): - AvitoSettings.from_env(env_file=env_file) + AvitoSettings.from_env(env_file=write_env_file(tmp_path / ".env", "AVITO_CLIENT_SECRET=x")) - -def test_avito_settings_from_env_requires_client_secret( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: clear_avito_env(monkeypatch) - env_file = write_env_file( - tmp_path / ".env", - "AVITO_AUTH__CLIENT_ID=client-id\n", - ) - with pytest.raises(ConfigurationError, match="client_secret"): - AvitoSettings.from_env(env_file=env_file) + AvitoSettings.from_env(env_file=write_env_file(tmp_path / ".env", "AVITO_CLIENT_ID=x")) def test_avito_settings_from_env_ignores_unsupported_generic_aliases( @@ -153,11 +140,6 @@ def test_avito_client_from_env_initializes_client( client.close() -def test_avito_client_requires_explicit_auth_fields() -> None: - with pytest.raises(ConfigurationError, match="client_secret"): - AvitoClient(AvitoSettings(auth=AuthSettings(client_id="client-id"))) - - def test_explicit_settings_do_not_implicitly_read_process_env( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -168,35 +150,7 @@ def test_explicit_settings_do_not_implicitly_read_process_env( assert settings.auth.client_secret is None -def test_debug_info_does_not_expose_secret_values( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - clear_avito_env(monkeypatch) - client = AvitoClient.from_env( - env_file=write_env_file( - tmp_path / ".env", - "\n".join( - ( - "AVITO_BASE_URL=https://sandbox.avito.ru", - "AVITO_USER_ID=99", - "AVITO_AUTH__CLIENT_ID=client-id", - "AVITO_AUTH__CLIENT_SECRET=super-secret", - ) - ), - ) - ) - try: - info = client.debug_info() - - assert info.base_url == "https://sandbox.avito.ru" - assert info.user_id == 99 - assert "super-secret" not in repr(info) - assert "authorization" not in repr(info).lower() - finally: - client.close() - - -def test_process_environment_overrides_dotenv_deterministically( +def test_process_environment_overrides_dotenv_and_parses_retry_options( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: clear_avito_env(monkeypatch) @@ -207,30 +161,6 @@ def test_process_environment_overrides_dotenv_deterministically( "AVITO_BASE_URL=https://from-file.avito.ru", "AVITO_AUTH__CLIENT_ID=file-client-id", "AVITO_AUTH__CLIENT_SECRET=file-client-secret", - ) - ), - ) - monkeypatch.setenv("AVITO_BASE_URL", "https://from-env.avito.ru") - monkeypatch.setenv("AVITO_CLIENT_ID", "env-client-id") - monkeypatch.setenv("AVITO_CLIENT_SECRET", "env-client-secret") - - settings = AvitoSettings.from_env(env_file=env_file) - - assert settings.base_url == "https://from-env.avito.ru" - assert settings.auth.client_id == "env-client-id" - assert settings.auth.client_secret == "env-client-secret" - - -def test_avito_settings_from_env_parses_timeout_and_retry_values( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - clear_avito_env(monkeypatch) - env_file = write_env_file( - tmp_path / ".env", - "\n".join( - ( - "AVITO_AUTH__CLIENT_ID=client-id", - "AVITO_AUTH__CLIENT_SECRET=client-secret", "AVITO_TIMEOUT_CONNECT=2.5", "AVITO_TIMEOUT_READ=11", "AVITO_RETRY_MAX_ATTEMPTS=4", @@ -241,9 +171,15 @@ def test_avito_settings_from_env_parses_timeout_and_retry_values( ) ), ) + monkeypatch.setenv("AVITO_BASE_URL", "https://from-env.avito.ru") + monkeypatch.setenv("AVITO_CLIENT_ID", "env-client-id") + monkeypatch.setenv("AVITO_CLIENT_SECRET", "env-client-secret") settings = AvitoSettings.from_env(env_file=env_file) + assert settings.base_url == "https://from-env.avito.ru" + assert settings.auth.client_id == "env-client-id" + assert settings.auth.client_secret == "env-client-secret" assert settings.timeouts.connect == 2.5 assert settings.timeouts.read == 11.0 assert settings.retry_policy.max_attempts == 4 diff --git a/tests/test_core.py b/tests/core/test_transport.py similarity index 60% rename from tests/test_core.py rename to tests/core/test_transport.py index b44fc51..58e778e 100644 --- a/tests/test_core.py +++ b/tests/core/test_transport.py @@ -20,6 +20,8 @@ ResponseMappingError, ServerError, Transport, + UnsupportedOperationError, + UpstreamApiError, ValidationError, ) from avito.core.retries import RetryPolicy @@ -112,89 +114,42 @@ def handler(request: httpx.Request) -> httpx.Response: sleep=lambda _: None, ) - with pytest.raises(Exception) as error: + with pytest.raises(Exception, match="offline"): transport.request_json("POST", "/items", context=RequestContext("create_item")) - assert "offline" in str(error.value) assert calls["count"] == 1 -def test_transport_handles_rate_limit_and_classifies_errors() -> None: - rate_limit_transport = Transport( - make_settings(retry_policy=RetryPolicy(max_attempts=1)), - client=httpx.Client( - transport=httpx.MockTransport( - lambda request: httpx.Response( - 429, headers={"retry-after": "60"}, json={"message": "too many"} - ) - ), - base_url="https://api.avito.ru", - ), - sleep=lambda _: None, - ) - - with pytest.raises(RateLimitError): - rate_limit_transport.request_json("GET", "/limited", context=RequestContext("limited")) - - server_transport = Transport( - make_settings(retry_policy=RetryPolicy(max_attempts=1)), - client=httpx.Client( - transport=httpx.MockTransport( - lambda request: httpx.Response(500, json={"message": "server down"}) - ), - base_url="https://api.avito.ru", - ), - sleep=lambda _: None, - ) - - with pytest.raises(ServerError): - server_transport.request_json("GET", "/server", context=RequestContext("server")) - - validation_transport = Transport( - make_settings(retry_policy=RetryPolicy(max_attempts=1)), - client=httpx.Client( - transport=httpx.MockTransport( - lambda request: httpx.Response(422, json={"message": "invalid"}) - ), - base_url="https://api.avito.ru", - ), - sleep=lambda _: None, - ) - - with pytest.raises(ValidationError): - validation_transport.request_json( - "POST", "/validation", context=RequestContext("validation") - ) - - authorization_transport = Transport( - make_settings(retry_policy=RetryPolicy(max_attempts=1)), - client=httpx.Client( - transport=httpx.MockTransport( - lambda request: httpx.Response(403, json={"message": "forbidden"}) - ), - base_url="https://api.avito.ru", - ), - sleep=lambda _: None, - ) - - with pytest.raises(AuthorizationError): - authorization_transport.request_json( - "GET", "/forbidden", context=RequestContext("forbidden") - ) - - conflict_transport = Transport( +@pytest.mark.parametrize( + ("status_code", "error_cls"), + ( + (400, ValidationError), + (401, AuthenticationError), + (403, AuthorizationError), + (405, UnsupportedOperationError), + (409, ConflictError), + (418, UpstreamApiError), + (422, ValidationError), + (429, RateLimitError), + (500, ServerError), + ), +) +def test_transport_maps_http_statuses_to_typed_sdk_errors( + status_code: int, error_cls: type[Exception] +) -> None: + transport = Transport( make_settings(retry_policy=RetryPolicy(max_attempts=1)), client=httpx.Client( transport=httpx.MockTransport( - lambda request: httpx.Response(409, json={"message": "conflict"}) + lambda request: httpx.Response(status_code, json={"message": "boom"}) ), base_url="https://api.avito.ru", ), sleep=lambda _: None, ) - with pytest.raises(ConflictError): - conflict_transport.request_json("POST", "/conflict", context=RequestContext("conflict")) + with pytest.raises(error_cls): + transport.request_json("GET", "/broken", context=RequestContext("broken")) def test_transport_raises_mapping_error_for_invalid_json() -> None: @@ -254,26 +209,17 @@ def handler(request: httpx.Request) -> httpx.Response: assert binary_result.filename == "label.pdf" -def test_paginator_collects_typed_pages() -> None: +def test_paginator_and_paginated_list_keep_lazy_contract() -> None: pages = { 1: JsonPage(items=[1, 2], page=1, per_page=2, total=5), 2: JsonPage(items=[3, 4], page=2, per_page=2, total=5), 3: JsonPage(items=[5], page=3, per_page=2, total=5), } + calls: list[int] = [] paginator = Paginator(lambda page, cursor: pages[page or 1]) - assert paginator.collect() == [1, 2, 3, 4, 5] - -def test_paginated_list_behaves_like_list_and_loads_pages_lazily() -> None: - calls: list[int] = [] - pages = { - 1: JsonPage(items=[1, 2], page=1, per_page=2, total=5), - 2: JsonPage(items=[3, 4], page=2, per_page=2, total=5), - 3: JsonPage(items=[5], page=3, per_page=2, total=5), - } - def fetch(page: int | None, cursor: str | None) -> JsonPage[int]: resolved_page = page or 1 calls.append(resolved_page) @@ -282,99 +228,29 @@ def fetch(page: int | None, cursor: str | None) -> JsonPage[int]: items = PaginatedList(fetch, first_page=pages[1]) assert items.loaded_count == 2 - assert items.is_materialized is False - assert items[0] == 1 - assert calls == [] - assert items[3] == 4 assert calls == [2] - assert items.loaded_count == 4 - assert items.is_materialized is False - - assert items[:] == [1, 2, 3, 4, 5] - assert calls == [2, 3] - assert items.loaded_count == 5 - assert items.is_materialized is True - - assert len(items) == 5 - assert items == [1, 2, 3, 4, 5] - - -def test_paginated_list_partial_iteration_fetches_only_required_pages() -> None: - calls: list[int] = [] - pages = { - 1: JsonPage(items=[1, 2], page=1, per_page=2, total=5), - 2: JsonPage(items=[3, 4], page=2, per_page=2, total=5), - 3: JsonPage(items=[5], page=3, per_page=2, total=5), - } - - def fetch(page: int | None, cursor: str | None) -> JsonPage[int]: - resolved_page = page or 1 - calls.append(resolved_page) - return pages[resolved_page] - - items = PaginatedList(fetch, first_page=pages[1]) - assert list(item for _, item in zip(range(3), items, strict=False)) == [1, 2, 3] - assert calls == [2] - assert items.loaded_count == 4 - assert items.is_materialized is False - - -def test_paginated_list_materialize_loads_all_remaining_pages_once() -> None: - calls: list[int] = [] - pages = { - 1: JsonPage(items=[1, 2], page=1, per_page=2, total=5), - 2: JsonPage(items=[3, 4], page=2, per_page=2, total=5), - 3: JsonPage(items=[5], page=3, per_page=2, total=5), - } - - def fetch(page: int | None, cursor: str | None) -> JsonPage[int]: - resolved_page = page or 1 - calls.append(resolved_page) - return pages[resolved_page] - - items = PaginatedList(fetch, first_page=pages[1]) - - assert items.materialize() == [1, 2, 3, 4, 5] assert items.materialize() == [1, 2, 3, 4, 5] - assert calls == [2, 3] assert items.is_materialized is True -def test_paginated_list_propagates_error_when_read_reaches_failing_page() -> None: - calls: list[int] = [] +def test_paginated_list_handles_empty_pages_and_failing_follow_up_page() -> None: + empty = PaginatedList( + lambda page, cursor: JsonPage(items=[], page=1, per_page=10, total=0), + first_page=JsonPage(items=[], page=1, per_page=10, total=0), + ) + assert empty.materialize() == [] def fetch(page: int | None, cursor: str | None) -> JsonPage[int]: - resolved_page = page or 1 - calls.append(resolved_page) - if resolved_page == 2: + if (page or 1) == 2: raise RateLimitError("page 2 failed") return JsonPage(items=[1, 2], page=1, per_page=2, total=4) items = PaginatedList(fetch, first_page=JsonPage(items=[1, 2], page=1, per_page=2, total=4)) - - assert items[0] == 1 with pytest.raises(RateLimitError, match="page 2 failed"): _ = items[2] - assert calls == [2] - - -def test_paginated_list_handles_empty_first_page_without_extra_calls() -> None: - calls: list[int] = [] - - def fetch(page: int | None, cursor: str | None) -> JsonPage[int]: - resolved_page = page or 1 - calls.append(resolved_page) - return JsonPage(items=[], page=resolved_page, per_page=10, total=0) - - items = PaginatedList(fetch, first_page=JsonPage(items=[], page=1, per_page=10, total=0)) - - assert items.materialize() == [] - assert calls == [] - assert items.loaded_count == 0 - assert items.is_materialized is True def test_transport_raises_authentication_error_after_failed_refresh() -> None: diff --git a/tests/domains/accounts/test_accounts.py b/tests/domains/accounts/test_accounts.py new file mode 100644 index 0000000..fb2118e --- /dev/null +++ b/tests/domains/accounts/test_accounts.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import json +from datetime import datetime + +import httpx + +from avito.accounts import Account, AccountHierarchy +from tests.helpers.transport import make_transport + + +def test_account_domain_maps_profile_balance_and_operations() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/core/v1/accounts/self": + return httpx.Response( + 200, json={"id": 7, "name": "Иван", "email": "user@example.com", "phone": "+7999"} + ) + if request.url.path == "/core/v1/accounts/7/balance/": + return httpx.Response( + 200, + json={"user_id": 7, "balance": {"real": 150.5, "bonus": 20.0, "currency": "RUB"}}, + ) + assert request.url.path == "/core/v1/accounts/operations_history/" + assert json.loads(request.content.decode()) == { + "dateFrom": "2025-01-01T00:00:00+00:00", + "limit": 2, + "offset": 0, + } + return httpx.Response( + 200, + json={ + "total": 1, + "operations": [ + { + "id": "op-1", + "created_at": "2025-01-02T12:00:00Z", + "amount": 120.0, + "type": "payment", + "status": "done", + } + ], + }, + ) + + transport = make_transport(httpx.MockTransport(handler)) + account = Account(transport, user_id=7) + + profile = account.get_self() + balance = account.get_balance() + history = account.get_operations_history( + date_from=datetime.fromisoformat("2025-01-01T00:00:00+00:00"), + limit=2, + ) + + assert profile.user_id == 7 + assert balance.total == 170.5 + assert len(history.materialize()) == 1 + assert history[0].operation_type == "payment" + + +def test_account_hierarchy_domain_maps_employees_phones_and_items() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/checkAhUserV1": + return httpx.Response(200, json={"user_id": 7, "is_active": True, "role": "manager"}) + if request.url.path == "/getEmployeesV1": + return httpx.Response( + 200, + json={"employees": [{"employee_id": 10, "user_id": 7, "name": "Пётр"}], "total": 1}, + ) + if request.url.path == "/listCompanyPhonesV1": + return httpx.Response( + 200, json={"phones": [{"id": 1, "phone": "+7000", "comment": "Основной"}]} + ) + if request.url.path == "/linkItemsV1": + assert json.loads(request.content.decode()) == {"employeeId": 10, "itemIds": [1, 2]} + return httpx.Response(200, json={"success": True, "message": "linked"}) + assert request.url.path == "/listItemsByEmployeeIdV1" + assert json.loads(request.content.decode()) == {"employeeId": 10, "limit": 5, "offset": 0} + return httpx.Response( + 200, + json={ + "items": [{"item_id": 1, "title": "Объявление", "status": "active", "price": 99}], + "total": 1, + }, + ) + + hierarchy = AccountHierarchy(make_transport(httpx.MockTransport(handler)), user_id=7) + + status = hierarchy.get_status() + employees = hierarchy.list_employees() + phones = hierarchy.list_company_phones() + linked = hierarchy.link_items(employee_id=10, item_ids=[1, 2]) + items = hierarchy.list_items_by_employee(employee_id=10, limit=5) + + 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 items[0].title == "Объявление" diff --git a/tests/domains/ads/test_ads.py b/tests/domains/ads/test_ads.py new file mode 100644 index 0000000..3a9b72c --- /dev/null +++ b/tests/domains/ads/test_ads.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import json +from datetime import datetime + +import httpx + +from avito.ads import Ad, AdPromotion, AdStats +from tests.helpers.transport import make_transport + + +def test_ads_list_uses_lazy_pagination_with_list_like_items() -> None: + seen_offsets: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/core/v1/items" + assert request.url.params["user_id"] == "7" + assert request.url.params["status"] == "active" + assert request.url.params["limit"] == "2" + + offset = request.url.params["offset"] + seen_offsets.append(offset) + page_items = { + "0": [{"id": 101, "title": "Смартфон"}, {"id": 102, "title": "Ноутбук"}], + "2": [{"id": 103, "title": "Планшет"}, {"id": 104, "title": "Наушники"}], + "4": [{"id": 105, "title": "Камера"}], + } + return httpx.Response(200, json={"items": page_items[offset], "total": 5}) + + ad = Ad(make_transport(httpx.MockTransport(handler)), user_id=7) + + items = ad.list(status="active", limit=2) + + assert seen_offsets == ["0"] + assert items[3].item_id == 104 + assert seen_offsets == ["0", "2"] + assert [item.title for item in items.materialize()] == [ + "Смартфон", + "Ноутбук", + "Планшет", + "Наушники", + "Камера", + ] + assert seen_offsets == ["0", "2", "4"] + + +def test_ads_domain_covers_item_stats_spendings_and_promotion() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/core/v1/accounts/7/items/101/": + return httpx.Response( + 200, + json={"id": 101, "user_id": 7, "title": "Смартфон", "price": 1000, "status": "active"}, + ) + if request.url.path == "/stats/v1/accounts/7/items": + return httpx.Response( + 200, json={"items": [{"item_id": 101, "views": 45, "contacts": 5, "favorites": 2}]} + ) + if request.url.path == "/core/v1/accounts/7/calls/stats/": + return httpx.Response( + 200, + json={"items": [{"item_id": 101, "calls": 3, "answered_calls": 2, "missed_calls": 1}]}, + ) + if request.url.path == "/stats/v2/accounts/7/spendings": + return httpx.Response( + 200, json={"items": [{"item_id": 101, "amount": 77.5, "service": "xl"}]} + ) + if request.url.path == "/core/v1/accounts/7/items/101/vas": + assert json.loads(request.content.decode()) == {"codes": ["xl"]} + return httpx.Response(200, json={"success": True, "status": "applied"}) + assert request.url.path == "/core/v1/items/101/update_price" + assert json.loads(request.content.decode()) == {"price": 1500} + return httpx.Response(200, json={"item_id": 101, "price": 1500, "status": "updated"}) + + transport = make_transport(httpx.MockTransport(handler)) + ad = Ad(transport, item_id=101, user_id=7) + stats = AdStats(transport, item_id=101, user_id=7) + promotion = AdPromotion(transport, item_id=101, user_id=7) + + item = ad.get() + updated = ad.update_price(price=1500) + item_stats = stats.get_item_stats() + calls = stats.get_calls_stats() + spendings = stats.get_account_spendings() + applied = promotion.apply_vas(codes=["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" + + +def test_ad_stats_accept_datetime_filters_and_serialize_isoformat() -> None: + seen_payloads: list[dict[str, object]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_payloads.append(json.loads(request.content.decode())) + return httpx.Response(200, json={"items": [{"item_id": 101, "views": 10}]}) + + stats = AdStats(make_transport(httpx.MockTransport(handler)), item_id=101, user_id=7) + started_at = datetime.fromisoformat("2026-04-18T00:00:00+03:00") + finished_at = datetime.fromisoformat("2026-04-18T23:59:59+03:00") + + stats.get_item_analytics(item_ids=[101], date_from=started_at, date_to=finished_at) + + assert seen_payloads[0]["dateFrom"] == "2026-04-18T00:00:00+03:00" + assert seen_payloads[0]["dateTo"] == "2026-04-18T23:59:59+03:00" diff --git a/tests/domains/autoteka/test_autoteka.py b/tests/domains/autoteka/test_autoteka.py new file mode 100644 index 0000000..a1ef8ef --- /dev/null +++ b/tests/domains/autoteka/test_autoteka.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import json + +import httpx + +from avito.autoteka import AutotekaMonitoring, AutotekaReport, AutotekaScoring, AutotekaValuation, AutotekaVehicle +from avito.autoteka.models import ( + CatalogResolveRequest, + ExternalItemPreviewRequest, + ItemIdRequest, + LeadsRequest, + MonitoringBucketRequest, + MonitoringEventsQuery, + PlateNumberRequest, + PreviewReportRequest, + RegNumberRequest, + TeaserCreateRequest, + ValuationBySpecificationRequest, + VehicleIdRequest, + VinRequest, +) +from tests.helpers.transport import make_transport + + +def test_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 == {"brandId": 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", "model": "A4"}}]}) + 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}}) + + vehicle = AutotekaVehicle(make_transport(httpx.MockTransport(handler)), vehicle_id="77") + + assert vehicle.resolve_catalog(request=CatalogResolveRequest(brand_id=1)).items[0].values[0].label == "Audi" + assert vehicle.get_leads(request=LeadsRequest(limit=1)).last_id == 321 + assert vehicle.create_preview_by_vin(request=VinRequest(vin="VIN-1")).preview_id == "77" + assert vehicle.create_preview_by_item_id(request=ItemIdRequest(item_id=901)).preview_id == "78" + assert vehicle.create_preview_by_reg_number(request=RegNumberRequest(reg_number="A123AA77")).preview_id == "79" + assert vehicle.create_preview_by_external_item(request=ExternalItemPreviewRequest(item_id="ext-1", site="cars.example")).preview_id == "80" + assert vehicle.get_preview().vehicle_id == "VIN-1" + assert vehicle.create_specification_by_plate_number(request=PlateNumberRequest(plate_number="A123AA77")).specification_id == "501" + assert vehicle.create_specification_by_vehicle_id(request=VehicleIdRequest(vehicle_id="VIN-1")).specification_id == "502" + assert vehicle.get_specification_by_id(specification_id="501").status == "success" + assert vehicle.create_teaser(request=TeaserCreateRequest(vehicle_id="VIN-1")).teaser_id == "601" + assert vehicle.get_teaser(teaser_id="601").brand == "Audi" + + +def test_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", "nextLink": "https://api.avito.ru/next"}}) + 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, "createdAt": 1713427200}}}) + return httpx.Response(200, json={"result": {"status": "success", "vehicleId": "VIN-1", "brand": "Audi", "model": "A4", "year": 2018, "ownersCount": "2", "mileage": 30000, "valuation": {"avgPriceWithCondition": 2100000, "avgMarketPrice": 2200000}}}) + + transport = make_transport(httpx.MockTransport(handler)) + report = AutotekaReport(transport, report_id="701") + monitoring = AutotekaMonitoring(transport) + scoring = AutotekaScoring(transport, scoring_id="801") + valuation = AutotekaValuation(transport) + + assert report.get_active_package().reports_remaining == 77 + assert report.create_report(request=PreviewReportRequest(preview_id=77)).report_id == "701" + assert report.create_report_by_vehicle_id(request=VehicleIdRequest(vehicle_id="VIN-1")).report_id == "702" + assert report.list_reports().items[0].vehicle_id == "VIN-1" + assert report.get_report().web_link == "https://autoteka/web/701" + assert report.create_sync_report_by_reg_number(request=RegNumberRequest(reg_number="A123AA77")).status == "success" + assert report.create_sync_report_by_vin(request=VinRequest(vin="VIN-1")).report_id == "704" + assert monitoring.create_monitoring_bucket_add(request=MonitoringBucketRequest(vehicles=["VIN-1", "bad-vin"])).invalid_vehicles[0].vehicle_id == "bad-vin" + assert monitoring.delete_bucket().success is True + assert monitoring.remove_bucket(request=MonitoringBucketRequest(vehicles=["VIN-1"])).success is True + assert monitoring.get_monitoring_reg_actions(query=MonitoringEventsQuery(limit=10)).items[0].operation_code == 11 + assert scoring.create_scoring_by_vehicle_id(request=VehicleIdRequest(vehicle_id="VIN-1")).scoring_id == "801" + assert scoring.get_scoring_by_id().is_completed is True + assert valuation.get_valuation_by_specification(request=ValuationBySpecificationRequest(specification_id=501, mileage=30000)).avg_price_with_condition == 2100000 diff --git a/tests/domains/cpa/test_cpa.py b/tests/domains/cpa/test_cpa.py new file mode 100644 index 0000000..34a5683 --- /dev/null +++ b/tests/domains/cpa/test_cpa.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import json + +import httpx + +from avito.cpa import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead +from avito.cpa.models import ( + CallTrackingCallsRequest, + CpaCallByIdRequest, + CpaCallComplaintRequest, + CpaCallsByTimeRequest, + CpaChatsByTimeRequest, + CpaLeadComplaintRequest, + CpaPhonesFromChatsRequest, +) +from tests.helpers.transport import make_transport + + +def test_cpa_chat_and_phone_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 == "/cpa/v1/chatByActionId/act-1": + return httpx.Response(200, json={"chat": {"chat": {"id": "chat-1", "actionId": "act-1"}, "buyer": {"userId": 501, "name": "Иван"}, "item": {"id": 9001, "title": "Велосипед"}, "isArbitrageAvailable": True}}) + if path == "/cpa/v1/chatsByTime": + assert payload == {"createdAtFrom": "2026-04-18T00:00:00+03:00"} + return httpx.Response(200, json={"chats": [{"chat": {"id": "chat-v1", "actionId": "legacy-1"}, "buyer": {"userId": 502, "name": "Петр"}, "item": {"id": 9002, "title": "Самокат"}, "isArbitrageAvailable": False}]}) + if path == "/cpa/v2/chatsByTime": + return httpx.Response(200, json={"chats": [{"chat": {"id": "chat-v2", "actionId": "act-2"}, "buyer": {"userId": 503, "name": "Мария"}, "item": {"id": 9003, "title": "Ноутбук"}, "isArbitrageAvailable": True}]}) + 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"}]}) + + chat = CpaChat(make_transport(httpx.MockTransport(handler)), action_id="act-1") + assert chat.get().item_title == "Велосипед" + assert chat.list(request=CpaChatsByTimeRequest(created_at_from="2026-04-18T00:00:00+03:00"), version=1).items[0].buyer_name == "Петр" + assert chat.list(request=CpaChatsByTimeRequest(created_at_from="2026-04-18T00:00:00+03:00", limit=10)).items[0].is_arbitrage_available is True + assert chat.get_phones_info_from_chats(request=CpaPhonesFromChatsRequest(action_ids=["act-1", "act-2"])).items[1].phone_number == "+79990000002" + + +def test_cpa_calls_archive_and_balance_flows() -> None: + audio_bytes = b"ID3 fake audio" + + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/cpa/v2/callsByTime": + return httpx.Response(200, json={"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"}]}) + if path == "/cpa/v1/createComplaint": + return httpx.Response(200, json={"success": True}) + if path == "/cpa/v1/createComplaintByActionId": + return httpx.Response(200, json={"success": True}) + if path == "/cpa/v3/balanceInfo": + return httpx.Response(200, json={"balance": -5000}) + if path == "/cpa/v2/balanceInfo": + return httpx.Response(200, json={"balance": -5000, "advance": 1000, "debt": 0}) + if path == "/cpa/v2/callById": + return httpx.Response(200, json={"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"}}) + return httpx.Response(200, content=audio_bytes, headers={"content-type": "audio/mpeg", "content-disposition": 'attachment; filename="call-2001.mp3"'}) + + transport = make_transport(httpx.MockTransport(handler)) + cpa_call = CpaCall(transport) + cpa_lead = CpaLead(transport) + archive = CpaArchive(transport, call_id="2001") + + assert cpa_call.list(request=CpaCallsByTimeRequest(date_time_from="2026-04-18T00:00:00+03:00", date_time_to="2026-04-18T23:59:59+03:00")).items[0].record_url == "https://example.com/record-2001.mp3" + assert cpa_call.create_complaint(request=CpaCallComplaintRequest(call_id=2001, reason="spam")).success is True + assert cpa_lead.create_complaint_by_action_id(request=CpaLeadComplaintRequest(action_id="act-1", reason="duplicate")).success is True + assert cpa_lead.get_balance_info().balance == -5000 + assert archive.get_balance_info().advance == 1000 + assert archive.get_call_by_id(request=CpaCallByIdRequest(call_id=2001)).call_id == "2001" + assert archive.get_call().binary.content == audio_bytes + + +def test_calltracking_flows() -> None: + audio_bytes = b"RIFF fake wave" + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/calltracking/v1/getCallById/": + return httpx.Response(200, json={"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": ""}}) + if request.url.path == "/calltracking/v1/getCalls/": + return httpx.Response(200, json={"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": ""}}) + return httpx.Response(200, content=audio_bytes, headers={"content-type": "audio/wav", "content-disposition": 'attachment; filename="record-7001.wav"'}) + + call = CallTrackingCall(make_transport(httpx.MockTransport(handler)), call_id="7001") + assert call.get().call.call_id == "7001" + assert call.list(request=CallTrackingCallsRequest(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 call.download().binary.content == audio_bytes diff --git a/tests/domains/jobs/test_jobs.py b/tests/domains/jobs/test_jobs.py new file mode 100644 index 0000000..03549c3 --- /dev/null +++ b/tests/domains/jobs/test_jobs.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import json + +import httpx + +from avito.jobs import Application, JobDictionary, JobWebhook, Resume, Vacancy +from avito.jobs.models import ( + ApplicationActionRequest, + ApplicationIdsQuery, + ApplicationIdsRequest, + ApplicationViewedItem, + ApplicationViewedRequest, + JobWebhookUpdateRequest, + ResumeSearchQuery, + VacancyArchiveRequest, + VacancyAutoRenewalRequest, + VacancyCreateRequest, + VacancyIdsRequest, + VacancyProlongateRequest, + VacancyUpdateRequest, +) +from tests.helpers.transport import make_transport + + +def test_application_webhook_and_resume_flows() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/job/v1/applications/get_ids": + return httpx.Response(200, json={"items": [{"id": "app-1", "updatedAt": "2026-04-18T10:00:00+03:00"}], "cursor": "app-1"}) + if path == "/job/v1/applications/get_by_ids": + return httpx.Response(200, json={"applies": [{"id": "app-1", "vacancy_id": 101, "state": "new", "is_viewed": False, "applicant": {"name": "Иван"}}]}) + if path == "/job/v1/applications/get_states": + return httpx.Response(200, json={"states": [{"slug": "new", "description": "Новый отклик"}]}) + if path == "/job/v1/applications/set_is_viewed": + return httpx.Response(200, json={"ok": True, "status": "viewed"}) + if path == "/job/v1/applications/apply_actions": + return httpx.Response(200, json={"ok": True, "status": "invited"}) + if path == "/job/v1/applications/webhook" and request.method == "GET": + return httpx.Response(200, json={"url": "https://example.com/job", "is_active": True, "version": "v1"}) + if path == "/job/v1/applications/webhook" and request.method == "PUT": + return httpx.Response(200, json={"url": "https://example.com/job", "is_active": True, "version": "v1"}) + if path == "/job/v1/applications/webhook" and request.method == "DELETE": + return httpx.Response(200, json={"ok": True}) + if path == "/job/v1/applications/webhooks": + return httpx.Response(200, json=[{"url": "https://example.com/job", "is_active": True, "version": "v1"}]) + if path == "/job/v1/resumes/": + return httpx.Response(200, json={"meta": {"cursor": "2", "total": 1}, "resumes": [{"id": "res-1", "title": "Оператор call-центра", "name": "Петр", "location": "Москва", "salary": 90000}]} ) + if path == "/job/v1/resumes/res-1/contacts/": + return httpx.Response(200, json={"name": "Петр", "phone": "+79990000000", "email": "petr@example.com"}) + return httpx.Response(200, json={"id": "res-1", "title": "Оператор call-центра", "fullName": "Петр Петров", "address_details": {"location": "Москва"}, "salary": {"from": 90000}}) + + transport = make_transport(httpx.MockTransport(handler)) + application = Application(transport) + webhook = JobWebhook(transport) + resume = Resume(transport, resume_id="res-1") + + assert application.list(query=ApplicationIdsQuery(updated_at_from="2026-04-18")).items[0].id == "app-1" + assert application.list(request=ApplicationIdsRequest(ids=["app-1"])).items[0].applicant_name == "Иван" + assert application.get_states().items[0].slug == "new" + assert application.update(request=ApplicationViewedRequest(applies=[ApplicationViewedItem(id="app-1", is_viewed=True)])).status == "viewed" + assert application.apply(request=ApplicationActionRequest(ids=["app-1"], action="invited")).status == "invited" + assert webhook.get().url == "https://example.com/job" + assert webhook.update(request=JobWebhookUpdateRequest(url="https://example.com/job")).is_active is True + assert webhook.delete(url="https://example.com/job").success is True + assert webhook.list().items[0].version == "v1" + assert resume.list(query=ResumeSearchQuery(query="оператор")).items[0].candidate_name == "Петр" + assert resume.get_contacts().phone == "+79990000000" + assert resume.get().location == "Москва" + + +def test_vacancy_and_dictionary_flows() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/job/v1/vacancies": + return httpx.Response(201, json={"id": 101, "status": "created"}) + if path == "/job/v1/vacancies/101": + return httpx.Response(200, json={"ok": True, "status": "updated"}) + if path == "/job/v1/vacancies/archived/101": + return httpx.Response(200, json={"ok": True, "status": "archived"}) + if path == "/job/v1/vacancies/101/prolongate": + return httpx.Response(200, json={"ok": True, "status": "prolongated"}) + if path == "/job/v2/vacancies" and request.method == "GET": + return httpx.Response(200, json={"vacancies": [{"id": 101, "uuid": "vac-uuid-1", "title": "Продавец", "status": "active"}], "total": 1}) + if path == "/job/v2/vacancies": + return httpx.Response(202, json={"vacancy_uuid": "vac-uuid-1", "status": "created"}) + if path == "/job/v2/vacancies/batch": + return httpx.Response(200, json={"vacancies": [{"id": 101, "uuid": "vac-uuid-1", "title": "Продавец", "status": "active"}]}) + if path == "/job/v2/vacancies/statuses": + return httpx.Response(200, json={"items": [{"id": 101, "uuid": "vac-uuid-1", "status": "active"}]}) + if path == "/job/v2/vacancies/update/vac-uuid-1": + return httpx.Response(202, json={"vacancy_uuid": "vac-uuid-1", "status": "updated"}) + if path == "/job/v2/vacancies/101": + return httpx.Response(200, json={"id": 101, "uuid": "vac-uuid-1", "title": "Продавец", "status": "active", "url": "https://avito.ru/vacancy/101"}) + if path == "/job/v2/vacancies/vac-uuid-1/auto_renewal": + return httpx.Response(200, json={"ok": True, "status": "auto-renewal-updated"}) + if path == "/job/v2/vacancy/dict": + return httpx.Response(200, json=[{"id": "profession", "description": "Профессия"}]) + return httpx.Response(200, json=[{"id": 10106, "name": "IT, интернет, телеком", "deprecated": True}]) + + transport = make_transport(httpx.MockTransport(handler)) + vacancy = Vacancy(transport, vacancy_id="101") + dictionary = JobDictionary(transport, dictionary_id="profession") + + assert vacancy.create(request=VacancyCreateRequest(title="Продавец"), version=1).id == "101" + assert vacancy.update(request=VacancyUpdateRequest(title="Старший продавец"), version=1).status == "updated" + assert vacancy.delete(request=VacancyArchiveRequest(employee_id=7)).status == "archived" + assert vacancy.prolongate(request=VacancyProlongateRequest(billing_type="package")).status == "prolongated" + assert vacancy.list().items[0].uuid == "vac-uuid-1" + assert vacancy.create(request=VacancyCreateRequest(title="Вакансия v2")).id == "vac-uuid-1" + assert vacancy.get_by_ids(request=VacancyIdsRequest(ids=[101])).items[0].title == "Продавец" + assert vacancy.get_statuses(request=VacancyIdsRequest(ids=[101])).items[0].status == "active" + assert vacancy.update(request=VacancyUpdateRequest(title="Вакансия v2 updated"), version=2, vacancy_uuid="vac-uuid-1").status == "updated" + assert vacancy.get().url == "https://avito.ru/vacancy/101" + assert vacancy.update_auto_renewal(request=VacancyAutoRenewalRequest(auto_renewal=True), vacancy_uuid="vac-uuid-1").status == "auto-renewal-updated" + assert dictionary.list().items[0].id == "profession" + assert dictionary.get().items[0].deprecated is True diff --git a/tests/domains/messenger/test_messenger.py b/tests/domains/messenger/test_messenger.py new file mode 100644 index 0000000..96a4576 --- /dev/null +++ b/tests/domains/messenger/test_messenger.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import json + +import httpx + +from avito.messenger import Chat, ChatMedia, ChatMessage, ChatWebhook, SpecialOfferCampaign +from avito.messenger.models import UploadImageFile +from tests.helpers.transport import make_transport + + +def test_messenger_chat_message_and_media_flows() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/messenger/v2/accounts/7/chats": + return httpx.Response(200, json={"chats": [{"id": "chat-1", "user_id": 7, "title": "Покупатель"}]}) + if path == "/messenger/v2/accounts/7/chats/chat-1": + return httpx.Response(200, json={"id": "chat-1", "user_id": 7, "title": "Покупатель"}) + if path == "/messenger/v1/accounts/7/chats/chat-1/messages": + assert json.loads(request.content.decode()) == {"message": "Здравствуйте"} + return httpx.Response(200, json={"success": True, "message_id": "msg-1", "status": "sent"}) + if path == "/messenger/v1/accounts/7/uploadImages": + return httpx.Response(200, json={"images": [{"image_id": "img-1", "url": "https://cdn/img-1.jpg"}]}) + assert path == "/messenger/v1/accounts/7/chats/chat-1/messages/image" + return httpx.Response(200, json={"success": True, "message_id": "msg-img-1", "status": "sent"}) + + transport = make_transport(httpx.MockTransport(handler)) + chat = Chat(transport, chat_id="chat-1", user_id=7) + message = ChatMessage(transport, user_id=7) + media = ChatMedia(transport, user_id=7) + + chats = chat.list() + info = chat.get() + sent = message.send_message(chat_id="chat-1", message="Здравствуйте") + uploaded = media.upload_images( + files=[UploadImageFile(field_name="image", filename="photo.jpg", content=b"binary", content_type="image/jpeg")] + ) + image_sent = message.send_image(chat_id="chat-1", image_id=uploaded.items[0].image_id or "") + + assert chats.items[0].chat_id == "chat-1" + assert info.title == "Покупатель" + assert sent.message_id == "msg-1" + assert uploaded.items[0].image_id == "img-1" + assert image_sent.message_id == "msg-img-1" + + +def test_messenger_webhook_and_special_offer_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 == "/messenger/v1/subscriptions": + return httpx.Response(200, json={"subscriptions": [{"url": "https://example.com/hook", "version": "v2", "status": "active"}]}) + if path == "/messenger/v3/webhook": + assert payload == {"url": "https://example.com/hook", "secret": "top-secret"} + return httpx.Response(200, json={"success": True, "status": "subscribed"}) + if path == "/special-offers/v1/multiCreate": + assert payload == {"itemIds": [1], "message": "Скидка 10%", "discountPercent": 10} + return httpx.Response(200, json={"campaign_id": "camp-1", "status": "draft"}) + if path == "/special-offers/v1/multiConfirm": + return httpx.Response(200, json={"success": True, "status": "confirmed"}) + assert path == "/special-offers/v1/stats" + return httpx.Response(200, json={"campaign_id": "camp-1", "sent_count": 20, "delivered_count": 18, "read_count": 10}) + + transport = make_transport(httpx.MockTransport(handler)) + webhook = ChatWebhook(transport) + campaign = SpecialOfferCampaign(transport, campaign_id="camp-1") + + subscriptions = webhook.list() + subscribed = webhook.subscribe(url="https://example.com/hook", secret="top-secret") + created = campaign.create_multi(item_ids=[1], message="Скидка 10%", discount_percent=10) + confirmed = campaign.confirm_multi() + stats = campaign.get_stats() + + assert subscriptions.items[0].status == "active" + assert subscribed.status == "subscribed" + assert created.status == "draft" + assert confirmed.status == "confirmed" + assert stats.delivered_count == 18 diff --git a/tests/domains/orders/test_orders.py b/tests/domains/orders/test_orders.py new file mode 100644 index 0000000..6a7ddc1 --- /dev/null +++ b/tests/domains/orders/test_orders.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import json + +import httpx + +from avito.orders import DeliveryOrder, DeliveryTask, Order, OrderLabel, SandboxDelivery, Stock +from avito.orders.models import ( + DeliveryAnnouncementRequest, + DeliveryParcelRequest, + DeliveryParcelResultRequest, + DeliveryParcelIdsRequest, + OrderAcceptReturnRequest, + OrderApplyTransitionRequest, + OrderConfirmationCodeRequest, + OrderCourierRangeRequest, + OrderLabelsRequest, + OrderMarkingsRequest, + OrderTrackingNumberRequest, + StockInfoRequest, + StockUpdateEntry, + StockUpdateRequest, +) +from tests.helpers.transport import make_transport + + +def test_order_management_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 == "/order-management/1/orders": + return httpx.Response(200, json={"orders": [{"id": "ord-1", "status": "new", "buyerInfo": {"fullName": "Иван"}}], "total": 1}) + if path == "/order-management/1/markings": + assert payload == {"orderId": "ord-1", "codes": ["abc"]} + return httpx.Response(200, json={"result": {"success": True, "orderId": "ord-1", "status": "marked"}}) + if path == "/order-management/1/order/applyTransition": + return httpx.Response(200, json={"result": {"success": True, "orderId": "ord-1", "status": "confirmed"}}) + if path == "/order-management/1/order/checkConfirmationCode": + return httpx.Response(200, json={"result": {"success": True, "orderId": "ord-1", "status": "code-valid"}}) + if path == "/order-management/1/order/getCourierDeliveryRange": + return httpx.Response(200, json={"result": {"address": "Москва", "timeIntervals": [{"id": "int-1", "date": "2026-04-18", "startAt": "10:00", "endAt": "12:00"}]}}) + if path == "/order-management/1/order/setCourierDeliveryRange": + return httpx.Response(200, json={"result": {"success": True, "status": "range-set"}}) + if path == "/order-management/1/order/setTrackingNumber": + return httpx.Response(200, json={"result": {"success": True, "status": "tracking-set"}}) + return httpx.Response(200, json={"result": {"success": True, "status": "return-accepted"}}) + + order = Order(make_transport(httpx.MockTransport(handler))) + assert order.list().items[0].buyer_name == "Иван" + assert order.update_markings(request=OrderMarkingsRequest(order_id="ord-1", codes=["abc"])).status == "marked" + assert order.apply(request=OrderApplyTransitionRequest(order_id="ord-1", transition="confirm")).status == "confirmed" + assert order.check_confirmation_code(request=OrderConfirmationCodeRequest(order_id="ord-1", code="1234")).status == "code-valid" + assert order.get_courier_delivery_range().items[0].interval_id == "int-1" + assert order.set_courier_delivery_range(request=OrderCourierRangeRequest(order_id="ord-1", interval_id="int-1")).status == "range-set" + assert order.update_tracking_number(request=OrderTrackingNumberRequest(order_id="ord-1", tracking_number="TRK-1")).status == "tracking-set" + assert order.accept_return_order(request=OrderAcceptReturnRequest(order_id="ord-1", postal_office_id="ops-1")).status == "return-accepted" + + +def test_labels_delivery_and_stock_flows() -> None: + pdf_bytes = b"%PDF-1.4 fake" + + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + payload = json.loads(request.content.decode()) if request.content else None + if path == "/order-management/1/orders/labels": + return httpx.Response(200, json={"result": {"taskId": 42, "status": "created"}}) + if path == "/order-management/1/orders/labels/42/download": + return httpx.Response(200, content=pdf_bytes, headers={"content-type": "application/pdf", "content-disposition": 'attachment; filename="label-42.pdf"'}) + if path == "/createAnnouncement": + assert payload == {"orderId": "ord-1"} + return httpx.Response(200, json={"data": {"taskId": 11, "status": "announcement-created"}}) + if path == "/createParcel": + return httpx.Response(200, json={"data": {"parcelId": "par-1", "status": "parcel-created"}}) + if path == "/cancelAnnouncement": + return httpx.Response(200, json={"data": {"status": "announcement-cancelled"}}) + if path == "/delivery/order/changeParcelResult": + return httpx.Response(200, json={"data": {"status": "callback-accepted"}}) + if path == "/sandbox/changeParcels": + return httpx.Response(200, json={"data": {"status": "parcels-updated"}}) + if path == "/delivery-sandbox/tasks/51": + return httpx.Response(200, json={"data": {"taskId": 51, "status": "done"}}) + if path == "/stock-management/1/info": + return httpx.Response(200, json={"stocks": [{"item_id": 123321, "quantity": 5, "is_multiple": True, "is_unlimited": False, "is_out_of_stock": False}]}) + if path == "/stock-management/1/stocks": + return httpx.Response(200, json={"stocks": [{"item_id": 123321, "external_id": "AB123456", "success": True, "errors": []}]}) + return httpx.Response(200, json={"data": {"taskId": 51, "status": "done"}}) + + transport = make_transport(httpx.MockTransport(handler)) + label = OrderLabel(transport, task_id="42") + delivery = DeliveryOrder(transport) + sandbox = SandboxDelivery(transport) + task = DeliveryTask(transport, task_id="51") + stock = Stock(transport) + + assert label.create(request=OrderLabelsRequest(order_ids=["ord-1"])).task_id == "42" + assert label.download().binary.content == pdf_bytes + assert delivery.create_announcement(request=DeliveryAnnouncementRequest(order_id="ord-1")).task_id == "11" + assert delivery.create(request=DeliveryParcelRequest(order_id="ord-1", parcel_id="par-1")).parcel_id == "par-1" + assert delivery.delete(request=DeliveryAnnouncementRequest(order_id="ord-1")).status == "announcement-cancelled" + assert delivery.create_change_parcel_result(request=DeliveryParcelResultRequest(parcel_id="par-1", result="ok")).status == "callback-accepted" + assert delivery.update_change_parcels(request=DeliveryParcelIdsRequest(parcel_ids=["par-1"])).status == "parcels-updated" + assert task.get().status == "done" + assert stock.get(request=StockInfoRequest(item_ids=[123321])).items[0].quantity == 5 + assert stock.update(request=StockUpdateRequest(stocks=[StockUpdateEntry(item_id=123321, quantity=7)])).items[0].success is True diff --git a/tests/domains/promotion/test_promotion.py b/tests/domains/promotion/test_promotion.py new file mode 100644 index 0000000..f251039 --- /dev/null +++ b/tests/domains/promotion/test_promotion.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import json +from datetime import datetime + +import httpx +import pytest + +from avito.ads import AdPromotion +from avito.core import ResponseMappingError +from avito.promotion import AutostrategyCampaign, BbipPromotion, CpaAuction, PromotionOrder, TargetActionPricing, TrxPromotion +from avito.promotion.models import ( + BbipItem, + CreateItemBid, +) +from tests.helpers.transport import make_transport + + +def test_promotion_service_dictionary_and_orders_flow() -> 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 == "/promotion/v1/items/services/dict": + return httpx.Response(200, json={"items": [{"code": "x2", "title": "X2"}]}) + if path == "/promotion/v1/items/services/get": + assert payload == {"itemIds": [101]} + return httpx.Response(200, json={"items": [{"itemId": 101, "serviceCode": "x2", "serviceName": "X2", "price": 9900, "status": "available"}]}) + if path == "/promotion/v1/items/services/orders/get": + assert payload == {"itemIds": [101]} + return httpx.Response(200, json={"items": [{"orderId": "ord-1", "itemId": 101, "serviceCode": "x2", "status": "created"}]}) + assert path == "/promotion/v1/items/services/orders/status" + return httpx.Response(200, json={"orderId": "ord-1", "status": "processed", "items": [], "errors": []}) + + promotion = PromotionOrder(make_transport(httpx.MockTransport(handler)), order_id="ord-1") + assert promotion.get_service_dictionary().items[0].code == "x2" + assert promotion.list_services(item_ids=[101]).items[0].price == 9900 + assert promotion.list_orders(item_ids=[101]).items[0].order_id == "ord-1" + assert promotion.get_order_status().status == "processed" + + +def test_bbip_trx_and_target_action_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 == "/promotion/v1/items/services/bbip/forecasts/get": + return httpx.Response(200, json={"items": [{"itemId": 101, "min": 10, "max": 25, "totalPrice": 7000, "totalOldPrice": 8400}]}) + if path == "/promotion/v1/items/services/bbip/orders/create": + return httpx.Response(200, json={"items": [{"itemId": 101, "success": True, "status": "created"}]}) + if path == "/promotion/v1/items/services/bbip/suggests/get": + return httpx.Response(200, json={"items": [{"itemId": 101, "duration": {"from": 1, "to": 7, "recommended": 5}, "budgets": [{"price": 1000, "oldPrice": 1200, "isRecommended": True}]}]}) + if path == "/trx-promo/1/apply": + return httpx.Response(200, json={"success": {"items": [{"itemID": 101, "success": True}]}}) + if path == "/trx-promo/1/cancel": + return httpx.Response(200, json={"success": {"items": [{"itemID": 101, "success": True}]}}) + if path == "/trx-promo/1/commissions": + return httpx.Response(200, json={"success": {"items": [{"itemID": 101, "commission": 1500, "isActive": True, "validCommissionRange": {"valueMin": 100, "valueMax": 2000, "step": 100}}]}}) + if path == "/auction/1/bids" and request.method == "GET": + return httpx.Response(200, json={"items": [{"itemID": 101, "pricePenny": 1300, "availablePrices": [{"pricePenny": 1200, "goodness": 1}]}]}) + if path == "/auction/1/bids": + assert payload == {"items": [{"itemID": 101, "pricePenny": 1500}]} + return httpx.Response(200, json={"items": [{"itemID": 101, "success": True}]}) + if path == "/cpxpromo/1/getBids/101": + return httpx.Response(200, json={"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, "compare": 10}]}}) + if path == "/cpxpromo/1/getPromotionsByItemIds": + return httpx.Response(200, json={"items": [{"itemID": 102, "actionTypeID": 7, "autoPromotion": {"budgetPenny": 9000, "budgetType": "7d"}}]}) + if path == "/cpxpromo/1/remove": + return httpx.Response(200, json={"items": [{"itemID": 101, "success": True, "status": "removed"}]}) + if path == "/cpxpromo/1/setAuto": + return httpx.Response(200, json={"items": [{"itemID": 101, "success": True, "status": "auto"}]}) + return httpx.Response(200, json={"items": [{"itemID": 101, "success": True, "status": "manual"}]}) + + transport = make_transport(httpx.MockTransport(handler)) + bbip = BbipPromotion(transport, item_id=101) + trx = TrxPromotion(transport, item_id=101) + auction = CpaAuction(transport) + pricing = TargetActionPricing(transport, item_id=101) + + bbip_item = BbipItem(item_id=101, duration=7, price=1000, old_price=1200).to_dict() + trx_item = { + "item_id": 101, + "commission": 1500, + "date_from": datetime.fromisoformat("2026-04-18T00:00:00+00:00"), + } + + assert bbip.get_forecasts(items=[bbip_item]).items[0].max_views == 25 + assert bbip.create_order(items=[bbip_item]).status == "created" + assert bbip.get_suggests().items[0].duration is not None + assert trx.apply(items=[trx_item]).applied is True + assert trx.delete().applied is True + assert trx.get_commissions().items[0].valid_commission_range is not None + assert auction.get_user_bids(from_item_id=100, batch_size=50).items[0].available_prices[0].price_penny == 1200 + assert auction.create_item_bids(items=[{"item_id": 101, "price_penny": 1500}]).applied is True + assert pricing.get_bids().manual is not None + assert pricing.get_promotions_by_item_ids(item_ids=[101, 102]).items[0].auto is not None + assert pricing.delete().status == "removed" + assert pricing.update_auto(action_type_id=5, budget_penny=8000, budget_type="7d").status == "auto" + assert pricing.update_manual(action_type_id=5, bid_penny=1500, limit_penny=15000).status == "manual" + + +def test_autostrategy_flows() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/autostrategy/v1/budget": + return httpx.Response(200, json={"calcId": 501, "budget": {"recommended": {"total": 10100}, "minimal": {"total": 5100}, "priceRanges": []}}) + if path == "/autostrategy/v1/campaign/create": + return httpx.Response(200, json={"campaign": {"campaignId": 77, "campaignType": "AS", "version": 3}}) + if path == "/autostrategy/v1/campaign/edit": + return httpx.Response(200, json={"campaign": {"campaignId": 77, "campaignType": "AS", "version": 4}}) + if path == "/autostrategy/v1/campaign/info": + return httpx.Response(200, json={"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}]}) + if path == "/autostrategy/v1/campaign/stop": + return httpx.Response(200, json={"campaign": {"campaignId": 77, "campaignType": "AS", "version": 5}}) + if path == "/autostrategy/v1/campaigns": + return httpx.Response(200, json={"campaigns": [{"campaignId": 77, "campaignType": "AS", "statusId": 1, "budget": 10000}], "totalCount": 1}) + return httpx.Response(200, json={"stat": [{"date": "2026-04-18", "calls": 30, "views": 500}], "totals": {"calls": 30, "views": 500}}) + + campaign = AutostrategyCampaign(make_transport(httpx.MockTransport(handler)), 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 campaign.create_budget(campaign_type="AS", start_time=start_time, finish_time=finish_time, items=[101, 102]).calc_id == 501 + assert 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 campaign.update(campaign_id=77, version=3, title="Обновленная кампания").campaign is not None + assert campaign.get().campaign is not None + assert campaign.delete(version=4).campaign is not None + assert 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 campaign.get_stat().totals is not None + + +def test_promotion_write_methods_keep_same_payload_in_dry_run_mode() -> None: + seen: list[tuple[str, dict[str, object]]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content.decode()) if request.content else {} + seen.append((request.url.path, payload)) + if request.url.path == "/core/v1/accounts/7/items/101/vas": + return httpx.Response(200, json={"success": True, "status": "applied"}) + if request.url.path == "/promotion/v1/items/services/bbip/orders/create": + return httpx.Response(200, json={"items": [{"itemId": 101, "success": True, "status": "created", "orderId": "ord-1"}]}) + return httpx.Response(200, json={"success": {"items": [{"itemID": 101, "success": True}]}}) + + transport = make_transport(httpx.MockTransport(handler)) + ad_promotion = AdPromotion(transport, item_id=101, user_id=7) + bbip = BbipPromotion(transport, item_id=101) + trx = TrxPromotion(transport, item_id=101) + bbip_item = BbipItem(item_id=101, duration=7, price=1000, old_price=1200).to_dict() + trx_item = { + "item_id": 101, + "commission": 1500, + "date_from": datetime.fromisoformat("2026-04-18T00:00:00+00:00"), + } + + vas_preview = ad_promotion.apply_vas(codes=["xl"], dry_run=True) + bbip_preview = bbip.create_order(items=[bbip_item], dry_run=True) + trx_preview = trx.apply(items=[trx_item], dry_run=True) + + assert vas_preview.status == "preview" + assert bbip_preview.status == "preview" + assert trx_preview.status == "preview" + + vas_apply = ad_promotion.apply_vas(codes=["xl"]) + bbip_apply = bbip.create_order(items=[bbip_item]) + trx_apply = trx.apply(items=[trx_item]) + + assert seen[0][1] == vas_preview.request_payload + assert seen[1][1] == bbip_preview.request_payload + assert seen[2][1] == trx_preview.request_payload + assert vas_apply.request_payload == vas_preview.request_payload + assert bbip_apply.request_payload == bbip_preview.request_payload + assert trx_apply.request_payload == trx_preview.request_payload + + +def test_promotion_read_mappers_raise_on_invalid_shape() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/promotion/v1/items/services/orders/status": + return httpx.Response(200, json={"items": []}) + if request.url.path == "/cpxpromo/1/getBids/101": + return httpx.Response(200, json={"items": []}) + return httpx.Response(200, json={"items": [{"itemID": 102}]}) + + transport = make_transport(httpx.MockTransport(handler)) + + with pytest.raises(ResponseMappingError): + PromotionOrder(transport, order_id="ord-2").get_order_status() + with pytest.raises(ResponseMappingError): + TargetActionPricing(transport, item_id=101).get_bids() + with pytest.raises(ResponseMappingError): + TargetActionPricing(transport, item_id=101).get_promotions_by_item_ids(item_ids=[102]) diff --git a/tests/domains/ratings/test_ratings.py b/tests/domains/ratings/test_ratings.py new file mode 100644 index 0000000..93e5aea --- /dev/null +++ b/tests/domains/ratings/test_ratings.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import json + +import httpx + +from avito.ratings import RatingProfile, Review, ReviewAnswer +from avito.ratings.models import ReviewsQuery +from tests.helpers.transport import make_transport + + +def test_ratings_flows() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/ratings/v1/answers": + assert json.loads(request.content.decode()) == {"reviewId": 123, "text": "Спасибо за отзыв"} + return httpx.Response(200, json={"id": 456, "createdAt": 1713427200}) + if path == "/ratings/v1/answers/456": + return httpx.Response(200, json={"success": True}) + if path == "/ratings/v1/info": + return httpx.Response(200, json={"isEnabled": True, "rating": {"score": 4.7, "reviewsCount": 25, "reviewsWithScoreCount": 20}}) + return httpx.Response(200, json={"total": 25, "reviews": [{"id": 123, "score": 5, "stage": "done", "text": "Все отлично", "createdAt": 1713427200, "canAnswer": True, "usedInScore": True}]}) + + transport = make_transport(httpx.MockTransport(handler)) + answer = ReviewAnswer(transport, answer_id="456") + profile = RatingProfile(transport) + review = Review(transport) + + assert answer.create(review_id=123, text="Спасибо за отзыв").answer_id == "456" + assert answer.delete().success is True + assert profile.get().score == 4.7 + assert review.list(query=ReviewsQuery(page=2)).items[0].text == "Все отлично" diff --git a/tests/domains/realty/test_realty.py b/tests/domains/realty/test_realty.py new file mode 100644 index 0000000..df22a25 --- /dev/null +++ b/tests/domains/realty/test_realty.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import json + +import httpx + +from avito.realty import RealtyAnalyticsReport, RealtyBooking, RealtyListing, RealtyPricing +from avito.realty.models import ( + RealtyBaseParamsUpdateRequest, + RealtyBookingsUpdateRequest, + RealtyInterval, + RealtyIntervalsRequest, + RealtyPricePeriod, + RealtyPricesUpdateRequest, +) +from tests.helpers.transport import make_transport + + +def test_realty_bookings_require_expected_params_and_map_fields() -> 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 == "/core/v1/accounts/10/items/20/bookings": + assert payload == {"blockedDates": ["2026-04-18"]} + return httpx.Response(200, json={"result": "success"}) + if path == "/realty/v1/accounts/10/items/20/bookings": + assert request.url.params["date_start"] == "2026-05-01" + assert request.url.params["date_end"] == "2026-05-05" + assert request.url.params["with_unpaid"] == "true" + return httpx.Response(200, json={"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}}]}) + if path == "/realty/v1/accounts/10/items/20/prices": + return httpx.Response(200, json={"result": "success"}) + if path == "/realty/v1/items/intervals": + return httpx.Response(200, json={"result": "success"}) + if path == "/realty/v1/items/20/base": + return httpx.Response(200, json={"result": "success"}) + if path == "/realty/v1/marketPriceCorrespondence/20/5000000": + return httpx.Response(200, json={"correspondence": "normal"}) + return httpx.Response(200, json={"success": {"success": {"reportLink": "https://example.com/realty-report/20"}}}) + + transport = make_transport(httpx.MockTransport(handler)) + booking = RealtyBooking(transport, item_id="20", user_id="10") + pricing = RealtyPricing(transport, item_id="20", user_id="10") + listing = RealtyListing(transport, item_id="20") + analytics = RealtyAnalyticsReport(transport, item_id="20") + + assert booking.update_bookings_info(request=RealtyBookingsUpdateRequest(blocked_dates=["2026-04-18"])).success is True + bookings = 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 pricing.update_realty_prices(request=RealtyPricesUpdateRequest(periods=[RealtyPricePeriod(date_from="2026-05-01", price=5000)])).status == "success" + assert listing.get_intervals(request=RealtyIntervalsRequest(item_id=20, intervals=[RealtyInterval(date="2026-05-01", available=True)])).success is True + assert listing.update_base_params(request=RealtyBaseParamsUpdateRequest(min_stay_days=2)).success is True + assert analytics.get_market_price_correspondence(price=5000000).correspondence == "normal" + assert analytics.get_report_for_classified().report_link == "https://example.com/realty-report/20" diff --git a/tests/domains/tariffs/test_tariffs.py b/tests/domains/tariffs/test_tariffs.py new file mode 100644 index 0000000..97a96b3 --- /dev/null +++ b/tests/domains/tariffs/test_tariffs.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import httpx + +from avito.tariffs import Tariff +from tests.helpers.transport import make_transport + + +def test_tariff_flow() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/tariff/info/1" + return httpx.Response( + 200, + json={ + "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}, + }, + }, + ) + + tariff = Tariff(make_transport(httpx.MockTransport(handler))) + info = tariff.get_tariff_info() + + assert info.current is not None + assert info.current.level == "Тариф Максимальный" + assert info.current.packages_count == 2 diff --git a/tests/helpers/transport.py b/tests/helpers/transport.py new file mode 100644 index 0000000..e9dc311 --- /dev/null +++ b/tests/helpers/transport.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import httpx + +from avito.auth import AuthSettings +from avito.config import AvitoSettings +from avito.core import Transport +from avito.core.retries import RetryPolicy +from avito.core.types import ApiTimeouts + + +def make_transport(handler: httpx.MockTransport, *, user_id: int | None = None) -> Transport: + settings = AvitoSettings( + base_url="https://api.avito.ru", + user_id=user_id, + auth=AuthSettings(), + retry_policy=RetryPolicy(), + timeouts=ApiTimeouts(), + ) + return Transport( + settings, + auth_provider=None, + client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), + sleep=lambda _: None, + ) diff --git a/tests/test_calltracking_contract_alignment.py b/tests/test_calltracking_contract_alignment.py deleted file mode 100644 index ed73ea5..0000000 --- a/tests/test_calltracking_contract_alignment.py +++ /dev/null @@ -1,100 +0,0 @@ -from __future__ import annotations - -import json - -import httpx - -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts -from avito.cpa import CallTrackingCall, CallTrackingCallResponse - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_calltracking_get_call_by_id_maps_call_and_error() -> None: - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/calltracking/v1/getCallById/" - assert json.loads(request.content.decode()) == {"callId": 7001} - return httpx.Response( - 200, - json={ - "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": ""}, - }, - ) - - result = CallTrackingCall( - make_transport(httpx.MockTransport(handler)), - resource_id="7001", - ).get() - - assert isinstance(result, CallTrackingCallResponse) - assert result.call.call_id == "7001" - assert result.call.item_id == "9901" - assert result.error.code == 0 - assert result.to_dict() == { - "call": { - "call_id": "7001", - "item_id": "9901", - "buyer_phone": "+79990000100", - "seller_phone": "+79990000101", - "virtual_phone": "+79990000102", - "call_time": "2026-04-18T09:00:00Z", - "talk_duration": 67, - "waiting_duration": 1.25, - }, - "error": {"code": 0, "message": ""}, - } - - -def test_calltracking_get_call_by_id_preserves_error_payload() -> None: - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - json={ - "call": { - "callId": 7002, - "itemId": 9902, - "buyerPhone": "+79990000200", - "sellerPhone": "+79990000201", - "virtualPhone": "+79990000202", - "callTime": "2026-04-18T10:00:00Z", - "talkDuration": 33, - "waitingDuration": 0.75, - }, - "error": {"code": 409, "message": "call is archived"}, - }, - ) - - result = CallTrackingCall( - make_transport(httpx.MockTransport(handler)), - resource_id="7002", - ).get() - - assert result.error.code == 409 - assert result.error.message == "call is archived" diff --git a/tests/test_no_raw_payload_contract.py b/tests/test_no_raw_payload_contract.py deleted file mode 100644 index 33abfa0..0000000 --- a/tests/test_no_raw_payload_contract.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -import importlib -from dataclasses import fields, is_dataclass -from inspect import isclass - -MODEL_MODULES = ( - "avito.accounts.models", - "avito.ads.models", - "avito.autoteka.models", - "avito.cpa.models", - "avito.jobs.models", - "avito.messenger.models", - "avito.orders.models", - "avito.promotion.models", - "avito.ratings.models", - "avito.realty.models", - "avito.tariffs.models", -) - - -def iter_public_dataclasses() -> list[tuple[str, str, type[object]]]: - classes: list[tuple[str, str, type[object]]] = [] - for module_name in MODEL_MODULES: - module = importlib.import_module(module_name) - for name, value in vars(module).items(): - if not isclass(value) or getattr(value, "__module__", None) != module_name: - continue - if not is_dataclass(value): - continue - classes.append((module_name, name, value)) - return classes - - -def test_no_public_model_declares_raw_payload_field() -> None: - offenders = [] - for module_name, name, cls in iter_public_dataclasses(): - if any(field.name in {"raw_payload", "_payload"} for field in fields(cls)): - offenders.append(f"{module_name}:{name}") - - assert offenders == [] diff --git a/tests/test_no_valueerror_in_public_surface.py b/tests/test_no_valueerror_in_public_surface.py deleted file mode 100644 index 599b37d..0000000 --- a/tests/test_no_valueerror_in_public_surface.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - - -def test_public_domain_and_client_surface_does_not_raise_valueerror() -> None: - root = Path(__file__).resolve().parent.parent / "avito" - offenders: list[str] = [] - - for path in root.glob("*/domain.py"): - text = path.read_text(encoding="utf-8") - if "raise ValueError" in text: - offenders.append(path.as_posix()) - - for path in root.glob("*/client.py"): - text = path.read_text(encoding="utf-8") - if "raise ValueError" in text: - offenders.append(path.as_posix()) - - assert offenders == [] diff --git a/tests/test_promotion_contract_alignment.py b/tests/test_promotion_contract_alignment.py deleted file mode 100644 index 98f3d21..0000000 --- a/tests/test_promotion_contract_alignment.py +++ /dev/null @@ -1,272 +0,0 @@ -from __future__ import annotations - -import json - -import httpx -import pytest - -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import ResponseMappingError, Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts -from avito.promotion import ( - PromotionOrder, - TargetActionGetBidsResult, - TargetActionPricing, - TargetActionPromotionsByItemIdsResult, -) - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_target_action_get_bids_maps_single_response() -> None: - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/cpxpromo/1/getBids/101" - return httpx.Response( - 200, - json={ - "actionTypeID": 5, - "selectedType": "manual", - "auto": { - "budgetPenny": 10000, - "budgetType": "daily", - "minBudgetPenny": 3000, - "maxBudgetPenny": 50000, - "dailyBudget": { - "budgets": [ - { - "valuePenny": 10000, - "minForecast": 1, - "maxForecast": 3, - "compare": 7, - } - ] - }, - }, - "manual": { - "bidPenny": 1400, - "limitPenny": 15000, - "recBidPenny": 1500, - "minBidPenny": 1000, - "maxBidPenny": 2000, - "minLimitPenny": 5000, - "maxLimitPenny": 50000, - "bids": [ - { - "valuePenny": 1500, - "minForecast": 2, - "maxForecast": 5, - "compare": 10, - } - ], - }, - }, - ) - - result = TargetActionPricing( - make_transport(httpx.MockTransport(handler)), resource_id=101 - ).get_bids() - - assert isinstance(result, TargetActionGetBidsResult) - assert result.action_type_id == 5 - assert result.selected_type == "manual" - assert result.auto is not None and result.auto.daily_budget[0].budget_penny == 10000 - assert result.manual is not None and result.manual.bids[0].compare == 10 - assert result.to_dict() == { - "action_type_id": 5, - "selected_type": "manual", - "auto": { - "budget_penny": 10000, - "budget_type": "daily", - "min_budget_penny": 3000, - "max_budget_penny": 50000, - "daily_budget": [ - { - "budget_penny": 10000, - "min_forecast": 1, - "max_forecast": 3, - "compare": 7, - } - ], - "weekly_budget": [], - "monthly_budget": [], - }, - "manual": { - "bid_penny": 1400, - "limit_penny": 15000, - "rec_bid_penny": 1500, - "min_bid_penny": 1000, - "max_bid_penny": 2000, - "min_limit_penny": 5000, - "max_limit_penny": 50000, - "bids": [ - { - "value_penny": 1500, - "min_forecast": 2, - "max_forecast": 5, - "compare": 10, - } - ], - }, - } - - -def test_target_action_get_promotions_by_item_ids_maps_items_list() -> None: - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/cpxpromo/1/getPromotionsByItemIds" - assert json.loads(request.content.decode()) == {"itemIDs": [101, 102]} - return httpx.Response( - 200, - json={ - "items": [ - { - "itemID": 101, - "actionTypeID": 5, - "manualPromotion": { - "bidPenny": 1400, - "limitPenny": 15000, - }, - }, - { - "itemID": 102, - "actionTypeID": 7, - "autoPromotion": { - "budgetPenny": 9000, - "budgetType": "7d", - }, - }, - ] - }, - ) - - result = TargetActionPricing( - make_transport(httpx.MockTransport(handler)), - resource_id=101, - ).get_promotions_by_item_ids(item_ids=[101, 102]) - - assert isinstance(result, TargetActionPromotionsByItemIdsResult) - assert result.items[0].manual is not None and result.items[0].manual.bid_penny == 1400 - assert result.items[1].auto is not None and result.items[1].auto.budget_type == "7d" - assert result.to_dict() == { - "items": [ - { - "item_id": 101, - "action_type_id": 5, - "auto": None, - "manual": {"bid_penny": 1400, "limit_penny": 15000}, - }, - { - "item_id": 102, - "action_type_id": 7, - "auto": {"budget_penny": 9000, "budget_type": "7d"}, - "manual": None, - }, - ] - } - - -def test_promotion_order_status_preserves_top_level_fields() -> None: - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/promotion/v1/items/services/orders/status" - assert json.loads(request.content.decode()) == {"orderIds": ["ord-1"]} - return httpx.Response( - 200, - json={ - "orderId": "ord-1", - "status": "processed", - "totalPrice": 26166, - "items": [], - "errors": [], - }, - ) - - result = PromotionOrder( - make_transport(httpx.MockTransport(handler)), - resource_id="ord-1", - ).get_order_status() - - assert result.order_id == "ord-1" - assert result.status == "processed" - assert result.total_price == 26166 - assert result.to_dict() == { - "order_id": "ord-1", - "status": "processed", - "total_price": 26166, - "items": [], - "errors": [], - } - - -def test_promotion_order_status_preserves_item_price_slug_and_error_reason() -> None: - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - json={ - "orderId": "ord-2", - "status": "partial", - "totalPrice": 10000, - "items": [ - { - "itemId": 101, - "price": 9900, - "slug": "x2", - "status": "processed", - "errorReason": "none", - } - ], - "errors": [{"itemId": 102, "errorCode": 1005, "errorText": "Недоступно"}], - }, - ) - - result = PromotionOrder( - make_transport(httpx.MockTransport(handler)), - resource_id="ord-2", - ).get_order_status() - - assert result.items[0].item_id == 101 - assert result.items[0].price == 9900 - assert result.items[0].slug == "x2" - assert result.items[0].error_reason == "none" - assert result.errors[0].error_code == 1005 - - -@pytest.mark.parametrize( - ("path", "body"), - [ - ("/cpxpromo/1/getBids/101", {"selectedType": "manual"}), - ("/cpxpromo/1/getPromotionsByItemIds", {"items": [{"itemID": 101}]}), - ("/promotion/v1/items/services/orders/status", {"status": "processed"}), - ], -) -def test_promotion_documented_shape_raises_response_mapping_error( - path: str, - body: dict[str, object], -) -> None: - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json=body) - - transport = make_transport(httpx.MockTransport(handler)) - - with pytest.raises(ResponseMappingError): - if path == "/cpxpromo/1/getBids/101": - TargetActionPricing(transport, resource_id=101).get_bids() - elif path == "/cpxpromo/1/getPromotionsByItemIds": - TargetActionPricing(transport, resource_id=101).get_promotions_by_item_ids( - item_ids=[101] - ) - else: - PromotionOrder(transport, resource_id="ord-1").get_order_status() diff --git a/tests/test_public_models.py b/tests/test_public_models.py deleted file mode 100644 index ac0b2f2..0000000 --- a/tests/test_public_models.py +++ /dev/null @@ -1,223 +0,0 @@ -from __future__ import annotations - -import json - -import httpx - -from avito.accounts import Account -from avito.accounts.models import AccountProfile -from avito.ads import Ad, AdStats -from avito.ads.models import AccountSpendings, CallStats, Listing, ListingStats, SpendingRecord -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts -from avito.promotion import ( - BbipForecastRequestItem, - BbipPromotion, - PromotionOrder, - PromotionService, -) -from avito.promotion.models import PromotionForecast -from avito.promotion.models import PromotionOrder as PromotionOrderModel - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_primary_sdk_models_serialize_without_transport_fields() -> None: - profile = AccountProfile( - id=7, - name="Иван", - email=None, - phone="+7999", - ) - listing = Listing( - id=101, - user_id=7, - title="Смартфон", - description=None, - status="active", - price=1000.0, - url=None, - ) - stats = ListingStats( - item_id=101, - views=42, - contacts=None, - favorites=3, - ) - calls = CallStats( - item_id=101, - calls=4, - answered_calls=3, - missed_calls=1, - ) - spendings = AccountSpendings( - items=[ - SpendingRecord( - item_id=101, - amount=77.5, - service="xl", - ) - ], - total=77.5, - ) - service = PromotionService( - item_id=101, - service_code="x2", - service_name="X2", - price=9900, - status="available", - ) - order = PromotionOrderModel( - order_id="ord-1", - item_id=101, - service_code="x2", - status="created", - created_at=None, - ) - forecast = PromotionForecast( - item_id=101, - min_views=10, - max_views=25, - total_price=7000, - total_old_price=None, - ) - - assert profile.to_dict() == {"id": 7, "name": "Иван", "email": None, "phone": "+7999"} - assert listing.to_dict() == { - "id": 101, - "user_id": 7, - "title": "Смартфон", - "description": None, - "status": "active", - "price": 1000.0, - "url": None, - } - assert stats.model_dump() == { - "item_id": 101, - "views": 42, - "contacts": None, - "favorites": 3, - } - assert calls.to_dict() == { - "item_id": 101, - "calls": 4, - "answered_calls": 3, - "missed_calls": 1, - } - assert spendings.to_dict() == { - "items": [{"item_id": 101, "amount": 77.5, "service": "xl"}], - "total": 77.5, - } - assert service.to_dict() == { - "item_id": 101, - "service_code": "x2", - "service_name": "X2", - "price": 9900, - "status": "available", - } - assert order.to_dict() == { - "order_id": "ord-1", - "item_id": 101, - "service_code": "x2", - "status": "created", - "created_at": None, - } - assert forecast.to_dict() == { - "item_id": 101, - "min_views": 10, - "max_views": 25, - "total_price": 7000, - "total_old_price": None, - } - assert isinstance(order, PromotionOrderModel) - - -def test_primary_read_methods_return_stable_sdk_models() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - if path == "/core/v1/accounts/self": - return httpx.Response(200, json={"id": 7, "name": "Иван"}) - if path == "/core/v1/accounts/7/items/101/": - return httpx.Response(200, json={"id": 101, "user_id": 7, "title": "Смартфон"}) - if path == "/stats/v1/accounts/7/items": - return httpx.Response( - 200, - json={"items": [{"item_id": 101, "views": 42, "contacts": 5, "favorites": 3}]}, - ) - if path == "/core/v1/accounts/7/calls/stats/": - return httpx.Response( - 200, - json={ - "items": [{"item_id": 101, "calls": 4, "answered_calls": 3, "missed_calls": 1}] - }, - ) - if path == "/stats/v2/accounts/7/spendings": - return httpx.Response( - 200, - json={"items": [{"item_id": 101, "amount": 77.5, "service": "xl"}]}, - ) - if path == "/promotion/v1/items/services/get": - assert json.loads(request.content.decode()) == {"itemIds": [101]} - return httpx.Response( - 200, - json={ - "items": [ - { - "itemId": 101, - "serviceCode": "x2", - "serviceName": "X2", - "price": 9900, - "status": "available", - } - ] - }, - ) - if path == "/promotion/v1/items/services/orders/get": - assert json.loads(request.content.decode()) == {"itemIds": [101]} - return httpx.Response( - 200, - json={"items": [{"orderId": "ord-1", "itemId": 101, "serviceCode": "x2"}]}, - ) - assert path == "/promotion/v1/items/services/bbip/forecasts/get" - return httpx.Response( - 200, - json={"items": [{"itemId": 101, "min": 10, "max": 25, "totalPrice": 7000}]}, - ) - - transport = make_transport(httpx.MockTransport(handler)) - - profile = Account(transport, user_id=7).get_self() - listing = Ad(transport, resource_id=101, user_id=7).get() - stats = AdStats(transport, resource_id=101, user_id=7).get_item_stats() - calls = AdStats(transport, resource_id=101, user_id=7).get_calls_stats() - spendings = AdStats(transport, resource_id=101, user_id=7).get_account_spendings() - services = PromotionOrder(transport, resource_id="ord-1").list_services(item_ids=[101]) - orders = PromotionOrder(transport, resource_id="ord-1").list_orders(item_ids=[101]) - forecasts = BbipPromotion(transport, resource_id=101).get_forecasts( - items=[BbipForecastRequestItem(item_id=101, duration=7, price=1000, old_price=1200)] - ) - - assert isinstance(profile, AccountProfile) - assert isinstance(listing, Listing) - assert isinstance(stats.items[0], ListingStats) - assert isinstance(calls.items[0], CallStats) - assert isinstance(spendings, AccountSpendings) - assert isinstance(services.items[0], PromotionService) - assert isinstance(orders.items[0], PromotionOrderModel) - assert isinstance(forecasts.items[0], PromotionForecast) diff --git a/tests/test_read_contract.py b/tests/test_read_contract.py deleted file mode 100644 index 46e6820..0000000 --- a/tests/test_read_contract.py +++ /dev/null @@ -1,128 +0,0 @@ -from __future__ import annotations - -import json - -import httpx - -from avito.accounts import Account -from avito.accounts.models import AccountProfile -from avito.ads import Ad, AdStats, Listing -from avito.ads.models import AccountSpendings, CallStats, ListingStats -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_read_methods_return_stable_sdk_models() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - if path == "/core/v1/accounts/self": - return httpx.Response(200, json={"id": 7, "name": "Иван", "email": "user@example.com"}) - if path == "/core/v1/accounts/7/items/101/": - return httpx.Response(200, json={"id": 101, "user_id": 7, "title": "Смартфон"}) - if path == "/core/v1/items": - assert request.url.params["user_id"] == "7" - return httpx.Response( - 200, json={"items": [{"id": 101, "title": "Смартфон"}], "total": 1} - ) - if path == "/stats/v1/accounts/7/items": - body = json.loads(request.content.decode()) - assert body["itemIds"] == [101] - return httpx.Response( - 200, - json={"items": [{"item_id": 101, "views": 42, "contacts": 5, "favorites": 3}]}, - ) - if path == "/core/v1/accounts/7/calls/stats/": - return httpx.Response( - 200, - json={ - "items": [{"item_id": 101, "calls": 4, "answered_calls": 3, "missed_calls": 1}] - }, - ) - assert path == "/stats/v2/accounts/7/spendings" - return httpx.Response( - 200, - json={"items": [{"item_id": 101, "amount": 77.5, "service": "xl"}]}, - ) - - transport = make_transport(httpx.MockTransport(handler)) - - profile = Account(transport, user_id=7).get_self() - listing = Ad(transport, resource_id=101, user_id=7).get() - listings = Ad(transport, user_id=7).list() - item_stats = AdStats(transport, resource_id=101, user_id=7).get_item_stats() - calls_stats = AdStats(transport, resource_id=101, user_id=7).get_calls_stats() - spendings = AdStats(transport, resource_id=101, user_id=7).get_account_spendings() - - assert isinstance(profile, AccountProfile) - assert isinstance(listing, Listing) - assert isinstance(listings.items[0], Listing) - assert isinstance(item_stats.items[0], ListingStats) - assert isinstance(calls_stats.items[0], CallStats) - assert isinstance(spendings, AccountSpendings) - - -def test_read_methods_handle_empty_and_partial_upstream_payloads() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - if path == "/core/v1/accounts/self": - return httpx.Response(200, json={}) - if path == "/core/v1/accounts/7/items/101/": - return httpx.Response(200, json={"id": 101}) - if path == "/core/v1/items": - return httpx.Response(200, json={}) - if path == "/stats/v1/accounts/7/items": - return httpx.Response(200, json={"items": [{"item_id": 101}]}) - if path == "/core/v1/accounts/7/calls/stats/": - return httpx.Response(200, json={}) - assert path == "/stats/v2/accounts/7/spendings" - return httpx.Response(200, json={"items": [{"item_id": 101}]}) - - transport = make_transport(httpx.MockTransport(handler)) - - profile = Account(transport, user_id=7).get_self() - listing = Ad(transport, resource_id=101, user_id=7).get() - listings = Ad(transport, user_id=7).list() - item_stats = AdStats(transport, resource_id=101, user_id=7).get_item_stats() - calls_stats = AdStats(transport, resource_id=101, user_id=7).get_calls_stats() - spendings = AdStats(transport, resource_id=101, user_id=7).get_account_spendings() - - assert profile.to_dict() == {"id": None, "name": None, "email": None, "phone": None} - assert listing.to_dict() == { - "id": 101, - "user_id": None, - "title": None, - "description": None, - "status": None, - "price": None, - "url": None, - } - assert listings.items == [] - assert item_stats.items[0].to_dict() == { - "item_id": 101, - "views": None, - "contacts": None, - "favorites": None, - } - assert calls_stats.items == [] - assert spendings.to_dict() == { - "items": [{"item_id": 101, "amount": None, "service": None}], - "total": None, - } diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py deleted file mode 100644 index d6c0a9d..0000000 --- a/tests/test_readme_examples.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -from datetime import datetime - -from avito.autoteka.models import CatalogResolveRequest, MonitoringBucketRequest -from avito.promotion.models import ( - CampaignListFilter, - CampaignOrderBy, - CampaignUpdateTimeFilter, - CreateAutostrategyBudgetRequest, - ListAutostrategyCampaignsRequest, -) - - -def test_autostrategy_request_models_produce_correct_payload() -> None: - budget_request = CreateAutostrategyBudgetRequest( - campaign_type="AS", - start_time=datetime.fromisoformat("2026-04-20T00:00:00+00:00"), - finish_time=datetime.fromisoformat("2026-04-27T00:00:00+00:00"), - items=[42, 43], - ) - campaigns_request = ListAutostrategyCampaignsRequest( - limit=50, - status_id=[1, 2], - order_by=[CampaignOrderBy(column="startTime", direction="asc")], - filter=CampaignListFilter( - by_update_time=CampaignUpdateTimeFilter( - from_time=datetime.fromisoformat("2026-04-01T00:00:00+00:00"), - to_time=datetime.fromisoformat("2026-04-30T00:00:00+00:00"), - ) - ), - ) - - assert budget_request.to_payload() == { - "campaignType": "AS", - "startTime": "2026-04-20T00:00:00+00:00", - "finishTime": "2026-04-27T00:00:00+00:00", - "items": [42, 43], - } - assert campaigns_request.to_payload() == { - "limit": 50, - "statusId": [1, 2], - "orderBy": [{"column": "startTime", "direction": "asc"}], - "filter": { - "byUpdateTime": { - "from": "2026-04-01T00:00:00+00:00", - "to": "2026-04-30T00:00:00+00:00", - } - }, - } - - -def test_autoteka_request_models_produce_correct_payload() -> None: - assert CatalogResolveRequest(brand_id=1).to_payload() == {"brandId": 1} - assert MonitoringBucketRequest(vehicles=["VIN-1"]).to_payload() == {"vehicles": ["VIN-1"]} diff --git a/tests/test_realty_contract_alignment.py b/tests/test_realty_contract_alignment.py deleted file mode 100644 index 95bbe8b..0000000 --- a/tests/test_realty_contract_alignment.py +++ /dev/null @@ -1,123 +0,0 @@ -from __future__ import annotations - -import inspect - -import httpx - -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts -from avito.realty import RealtyBooking - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_realty_bookings_requires_date_start_and_date_end() -> None: - signature = inspect.signature(RealtyBooking.list_realty_bookings) - - assert signature.parameters["date_start"].default is inspect._empty - assert signature.parameters["date_end"].default is inspect._empty - - -def test_realty_bookings_sends_required_query_params() -> None: - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/realty/v1/accounts/10/items/20/bookings" - assert request.url.params["date_start"] == "2026-05-01" - assert request.url.params["date_end"] == "2026-05-05" - assert request.url.params["with_unpaid"] == "true" - return httpx.Response(200, json={"bookings": []}) - - result = RealtyBooking( - make_transport(httpx.MockTransport(handler)), - resource_id="20", - user_id="10", - ).list_realty_bookings( - date_start="2026-05-01", - date_end="2026-05-05", - with_unpaid=True, - ) - - assert result.to_dict() == {"items": []} - - -def test_realty_bookings_maps_documented_fields() -> None: - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response( - 200, - json={ - "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, - }, - } - ] - }, - ) - - result = RealtyBooking( - make_transport(httpx.MockTransport(handler)), - resource_id="20", - user_id="10", - ).list_realty_bookings( - date_start="2026-05-01", - date_end="2026-05-05", - ) - - assert result.items[0].booking_id == 777 - assert ( - result.items[0].contact is not None and result.items[0].contact.email == "ivan@example.com" - ) - assert result.items[0].safe_deposit is not None - assert result.to_dict() == { - "items": [ - { - "booking_id": 777, - "base_price": 12000, - "check_in": "2026-05-01", - "check_out": "2026-05-05", - "contact": { - "name": "Иван", - "email": "ivan@example.com", - "phone": "9997770000", - }, - "guest_count": 2, - "nights": 4, - "safe_deposit": { - "owner_amount": 4500, - "tax": 500, - "total_amount": 5000, - }, - "status": "active", - } - ] - } diff --git a/tests/test_stage10_autoteka.py b/tests/test_stage10_autoteka.py deleted file mode 100644 index 42a833c..0000000 --- a/tests/test_stage10_autoteka.py +++ /dev/null @@ -1,383 +0,0 @@ -from __future__ import annotations - -import json - -import httpx - -from avito.auth import AuthSettings -from avito.autoteka import ( - AutotekaMonitoring, - AutotekaReport, - AutotekaScoring, - AutotekaValuation, - AutotekaVehicle, -) -from avito.autoteka.models import ( - CatalogResolveRequest, - ExternalItemPreviewRequest, - ItemIdRequest, - LeadsRequest, - MonitoringBucketRequest, - MonitoringEventsQuery, - PlateNumberRequest, - PreviewReportRequest, - RegNumberRequest, - TeaserCreateRequest, - ValuationBySpecificationRequest, - VehicleIdRequest, - VinRequest, -) -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_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 == {"brandId": 1} - return httpx.Response( - 200, - json={ - "result": { - "fields": [ - { - "id": 110000, - "label": "Марка", - "dataType": "integer", - "values": [{"valueId": 1, "label": "Audi"}], - } - ] - } - }, - ) - if path == "/autoteka/v1/get-leads/": - assert payload == {"limit": 1} - return httpx.Response( - 200, - json={ - "pagination": {"lastId": 321}, - "result": [ - { - "id": 12, - "subscriptionId": 44, - "payload": { - "vin": "VIN-1", - "itemId": 901, - "brand": "Audi", - "model": "A4", - "price": 1500000, - "itemCreatedAt": "2026-04-18 10:00", - "url": "https://avito.ru/item/901", - }, - } - ], - }, - ) - if path == "/autoteka/v1/previews": - assert payload == {"vin": "VIN-1"} - return httpx.Response(200, json={"result": {"preview": {"previewId": 77}}}) - if path == "/autoteka/v1/request-preview-by-item-id": - assert payload == {"itemId": 901} - return httpx.Response(200, json={"result": {"preview": {"previewId": 78}}}) - if path == "/autoteka/v1/request-preview-by-regnumber": - assert payload == {"regNumber": "A123AA77"} - return httpx.Response(200, json={"result": {"preview": {"previewId": 79}}}) - if path == "/autoteka/v1/request-preview-by-external-item": - assert payload == {"itemId": "ext-1", "site": "cars.example"} - 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": - assert payload == {"plateNumber": "A123AA77"} - return httpx.Response(200, json={"result": {"specification": {"specificationId": 501}}}) - if path == "/autoteka/v1/specifications/by-vehicle-id": - assert payload == {"vehicleId": "VIN-1"} - 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", - "plateNumber": "A123AA77", - } - } - }, - ) - if path == "/autoteka/v1/teasers": - assert payload == {"vehicleId": "VIN-1"} - return httpx.Response( - 200, json={"result": {"teaser": {"teaserId": 601, "status": "processing"}}} - ) - assert path == "/autoteka/v1/teasers/601" - return httpx.Response( - 200, - json={ - "teaserId": 601, - "status": "success", - "data": {"brand": "Audi", "model": "A4", "year": 2018}, - }, - ) - - vehicle = AutotekaVehicle(make_transport(httpx.MockTransport(handler)), resource_id="77") - - catalog = vehicle.resolve_catalog(request=CatalogResolveRequest(brand_id=1)) - leads = vehicle.get_leads(request=LeadsRequest(limit=1)) - preview_vin = vehicle.create_preview_by_vin(request=VinRequest(vin="VIN-1")) - preview_item = vehicle.create_preview_by_item_id(request=ItemIdRequest(item_id=901)) - preview_reg = vehicle.create_preview_by_reg_number( - request=RegNumberRequest(reg_number="A123AA77") - ) - preview_external = vehicle.create_preview_by_external_item( - request=ExternalItemPreviewRequest(item_id="ext-1", site="cars.example") - ) - preview = vehicle.get_preview() - specification_plate = vehicle.create_specification_by_plate_number( - request=PlateNumberRequest(plate_number="A123AA77") - ) - specification_vehicle = vehicle.create_specification_by_vehicle_id( - request=VehicleIdRequest(vehicle_id="VIN-1") - ) - specification = vehicle.get_specification_by_id(specification_id="501") - teaser_create = vehicle.create_teaser(request=TeaserCreateRequest(vehicle_id="VIN-1")) - teaser = vehicle.get_teaser(teaser_id="601") - - assert catalog.items[0].values[0].label == "Audi" - assert leads.last_id == 321 - assert leads.items[0].brand == "Audi" - assert preview_vin.preview_id == "77" - assert preview_item.preview_id == "78" - assert preview_reg.preview_id == "79" - assert preview_external.preview_id == "80" - assert preview.vehicle_id == "VIN-1" - assert specification_plate.specification_id == "501" - assert specification_vehicle.specification_id == "502" - assert specification.status == "success" - assert teaser_create.teaser_id == "601" - assert teaser.brand == "Audi" - - -def test_autoteka_report_monitoring_scoring_and_valuation_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/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": - assert payload == {"previewId": 77} - return httpx.Response( - 200, json={"result": {"report": {"reportId": 701, "status": "processing"}}} - ) - if path == "/autoteka/v1/reports-by-vehicle-id": - assert payload == {"vehicleId": "VIN-1"} - 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": - assert payload == {"regNumber": "A123AA77"} - return httpx.Response( - 200, - json={ - "result": { - "report": {"reportId": 703, "status": "success", "data": {"vin": "VIN-1"}} - } - }, - ) - if path == "/autoteka/v1/sync/create-by-vin": - assert payload == {"vin": "VIN-1"} - return httpx.Response( - 200, - json={ - "result": { - "report": {"reportId": 704, "status": "success", "data": {"vin": "VIN-1"}} - } - }, - ) - if path == "/autoteka/v1/monitoring/bucket/add": - assert payload == {"vehicles": ["VIN-1", "bad-vin"]} - 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": - assert payload == {"vehicles": ["VIN-1"]} - return httpx.Response(200, json={"result": {"isOk": True, "invalidVehicles": []}}) - if path == "/autoteka/v1/monitoring/get-reg-actions/": - assert request.url.params["limit"] == "10" - 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", - "nextLink": "https://api.avito.ru/next", - }, - }, - ) - if path == "/autoteka/v1/scoring/by-vehicle-id": - assert payload == {"vehicleId": "VIN-1"} - 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, - "createdAt": 1713427200, - } - } - }, - ) - assert path == "/autoteka/v1/valuation/by-specification" - assert payload == {"specificationId": 501, "mileage": 30000} - return httpx.Response( - 200, - json={ - "result": { - "status": "success", - "vehicleId": "VIN-1", - "brand": "Audi", - "model": "A4", - "year": 2018, - "ownersCount": "2", - "mileage": 30000, - "valuation": {"avgPriceWithCondition": 2100000, "avgMarketPrice": 2200000}, - } - }, - ) - - transport = make_transport(httpx.MockTransport(handler)) - report = AutotekaReport(transport, resource_id="701") - monitoring = AutotekaMonitoring(transport) - scoring = AutotekaScoring(transport, resource_id="801") - valuation = AutotekaValuation(transport) - - package = report.get_active_package() - created = report.create_report(request=PreviewReportRequest(preview_id=77)) - created_by_vehicle = report.create_report_by_vehicle_id( - request=VehicleIdRequest(vehicle_id="VIN-1") - ) - reports = report.list_reports() - fetched = report.get_report() - sync_reg = report.create_sync_report_by_reg_number( - request=RegNumberRequest(reg_number="A123AA77") - ) - sync_vin = report.create_sync_report_by_vin(request=VinRequest(vin="VIN-1")) - added = monitoring.create_monitoring_bucket_add( - request=MonitoringBucketRequest(vehicles=["VIN-1", "bad-vin"]) - ) - deleted = monitoring.delete_bucket() - removed = monitoring.remove_bucket(request=MonitoringBucketRequest(vehicles=["VIN-1"])) - events = monitoring.get_monitoring_reg_actions(query=MonitoringEventsQuery(limit=10)) - scoring_created = scoring.create_scoring_by_vehicle_id( - request=VehicleIdRequest(vehicle_id="VIN-1") - ) - scoring_item = scoring.get_scoring_by_id() - valuation_item = valuation.get_valuation_by_specification( - request=ValuationBySpecificationRequest(specification_id=501, mileage=30000) - ) - - assert package.reports_remaining == 77 - assert created.report_id == "701" - assert created_by_vehicle.report_id == "702" - assert reports.items[0].vehicle_id == "VIN-1" - assert fetched.web_link == "https://autoteka/web/701" - assert sync_reg.status == "success" - assert sync_vin.report_id == "704" - assert added.invalid_vehicles[0].vehicle_id == "bad-vin" - assert deleted.success is True - assert removed.success is True - assert events.has_next is True - assert events.items[0].operation_code == 11 - assert scoring_created.scoring_id == "801" - assert scoring_item.is_completed is True - assert valuation_item.avg_price_with_condition == 2100000 diff --git a/tests/test_stage11_mock_transport_suite.py b/tests/test_stage11_mock_transport_suite.py deleted file mode 100644 index 3a7fef6..0000000 --- a/tests/test_stage11_mock_transport_suite.py +++ /dev/null @@ -1,496 +0,0 @@ -from __future__ import annotations - -import httpx -import pytest - -from avito.accounts import Account -from avito.ads import Ad, AdPromotion, AdStats -from avito.core import ( - AuthorizationError, - ConflictError, - RateLimitError, - RetryPolicy, - UnsupportedOperationError, - UpstreamApiError, - ValidationError, -) -from avito.promotion import ( - BbipForecastRequestItem, - BbipOrderItem, - BbipPromotion, - PromotionOrder, - TargetActionPricing, - TrxPromotion, -) -from avito.promotion.models import TrxPromotionApplyItem -from tests.fake_transport import FakeTransport, json_response - - -def test_mock_transport_happy_path_read_methods_and_contract_snapshots( - fake_transport: FakeTransport, -) -> None: - fake_transport.add_json( - "GET", - "/core/v1/accounts/self", - {"id": 7, "name": "Иван", "email": "ivan@example.com", "phone": "+79990000000"}, - ) - fake_transport.add( - "GET", - "/core/v1/accounts/7/items/101/", - lambda request: json_response( - { - "id": 101, - "user_id": 7, - "title": "Смартфон", - "price": 1000, - "status": "active", - "transport_debug": "ignored", - } - ), - ) - fake_transport.add( - "GET", - "/core/v1/items", - lambda request: json_response( - { - "items": { - "0": [ - {"id": 101, "title": "Смартфон", "status": "active"}, - {"id": 102, "title": "Ноутбук", "status": "active"}, - ], - "2": [{"id": 103, "title": "Планшет", "status": "draft"}], - }[request.params["offset"]], - "total": 3, - } - ), - ) - fake_transport.add_json( - "POST", - "/stats/v1/accounts/7/items", - {"items": [{"item_id": 101, "views": 45, "contacts": 5, "favorites": 2}]}, - ) - fake_transport.add_json( - "POST", - "/core/v1/accounts/7/calls/stats/", - {"items": [{"item_id": 101, "calls": 3, "answered_calls": 2, "missed_calls": 1}]}, - ) - fake_transport.add_json( - "POST", - "/stats/v2/accounts/7/spendings", - {"items": [{"item_id": 101, "amount": 77.5, "service": "x2"}]}, - ) - fake_transport.add_json( - "POST", - "/promotion/v1/items/services/get", - { - "items": [ - { - "itemId": 101, - "serviceCode": "x2", - "serviceName": "X2", - "price": 9900, - "status": "available", - "internalOnly": "ignored", - } - ] - }, - ) - fake_transport.add_json( - "POST", - "/promotion/v1/items/services/orders/get", - { - "items": [ - { - "orderId": "ord-1", - "itemId": 101, - "serviceCode": "x2", - "status": "created", - "createdAt": "2026-04-18T10:00:00+03:00", - } - ] - }, - ) - fake_transport.add_json( - "POST", - "/promotion/v1/items/services/orders/status", - {"orderId": "ord-1", "status": "processed", "items": [], "errors": []}, - ) - fake_transport.add_json( - "POST", - "/promotion/v1/items/services/bbip/forecasts/get", - { - "items": [ - {"itemId": 101, "min": 10, "max": 25, "totalPrice": 7000, "totalOldPrice": 8400} - ] - }, - ) - fake_transport.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}], - } - ] - }, - ) - fake_transport.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}], - }, - }, - ) - fake_transport.add_json( - "POST", - "/cpxpromo/1/getPromotionsByItemIds", - { - "items": [ - { - "itemID": 102, - "actionTypeID": 7, - "autoPromotion": {"budgetPenny": 9000, "budgetType": "7d"}, - } - ] - }, - ) - - transport = fake_transport.build() - account = Account(transport, user_id=7) - ad = Ad(transport, resource_id=101, user_id=7) - stats = AdStats(transport, resource_id=101, user_id=7) - promotion_order = PromotionOrder(transport, resource_id="ord-1") - bbip = BbipPromotion(transport, resource_id=101) - pricing = TargetActionPricing(transport, resource_id=101) - - profile = account.get_self() - listing = ad.get() - listings = ad.list(status="active", limit=2) - item_stats = stats.get_item_stats() - call_stats = stats.get_calls_stats() - spendings = stats.get_account_spendings() - services = promotion_order.list_services(item_ids=[101]) - orders = promotion_order.list_orders(item_ids=[101]) - statuses = promotion_order.get_order_status() - forecasts = bbip.get_forecasts( - items=[BbipForecastRequestItem(item_id=101, duration=7, price=1000, old_price=1200)] - ) - suggests = bbip.get_suggests() - bids = pricing.get_bids() - promotions = pricing.get_promotions_by_item_ids(item_ids=[101, 102]) - - assert profile.to_dict() == { - "id": 7, - "name": "Иван", - "email": "ivan@example.com", - "phone": "+79990000000", - } - assert listing.to_dict() == { - "id": 101, - "user_id": 7, - "title": "Смартфон", - "description": None, - "status": "active", - "price": 1000, - "url": None, - } - assert listings.items.loaded_count == 2 - assert listings.items[0].title == "Смартфон" - assert listings.items[2].title == "Планшет" - assert fake_transport.count(method="GET", path="/core/v1/items") == 2 - assert item_stats.to_dict() == { - "items": [{"item_id": 101, "views": 45, "contacts": 5, "favorites": 2}] - } - assert call_stats.to_dict() == { - "items": [{"item_id": 101, "calls": 3, "answered_calls": 2, "missed_calls": 1}] - } - assert spendings.to_dict() == { - "items": [{"item_id": 101, "amount": 77.5, "service": "x2"}], - "total": 77.5, - } - assert services.to_dict() == { - "items": [ - { - "item_id": 101, - "service_code": "x2", - "service_name": "X2", - "price": 9900, - "status": "available", - } - ] - } - assert orders.to_dict() == { - "items": [ - { - "order_id": "ord-1", - "item_id": 101, - "service_code": "x2", - "status": "created", - "created_at": "2026-04-18T10:00:00+03:00", - } - ] - } - assert statuses.to_dict() == { - "order_id": "ord-1", - "status": "processed", - "total_price": None, - "items": [], - "errors": [], - } - assert forecasts.to_dict() == { - "items": [ - { - "item_id": 101, - "min_views": 10, - "max_views": 25, - "total_price": 7000, - "total_old_price": 8400, - } - ] - } - assert suggests.to_dict() == { - "items": [ - { - "item_id": 101, - "duration": {"start": 1, "stop": 7, "recommended": 5}, - "budgets": [{"price": 1000, "old_price": 1200, "is_recommended": True}], - } - ] - } - assert bids.to_dict() == { - "action_type_id": 5, - "selected_type": "manual", - "auto": None, - "manual": { - "bid_penny": 1400, - "limit_penny": 15000, - "rec_bid_penny": 1500, - "min_bid_penny": 1000, - "max_bid_penny": 2000, - "min_limit_penny": 5000, - "max_limit_penny": 50000, - "bids": [ - { - "value_penny": 1500, - "min_forecast": 2, - "max_forecast": 5, - "compare": None, - } - ], - }, - } - assert promotions.to_dict() == { - "items": [ - { - "item_id": 102, - "action_type_id": 7, - "auto": {"budget_penny": 9000, "budget_type": "7d"}, - "manual": None, - } - ] - } - - -def test_mock_transport_happy_path_write_methods_and_dry_run( - fake_transport: FakeTransport, -) -> None: - fake_transport.add_json( - "PUT", - "/core/v1/accounts/7/items/101/vas", - {"success": True, "status": "applied"}, - ) - fake_transport.add_json( - "PUT", - "/core/v2/accounts/7/items/101/vas_packages", - {"success": True, "status": "package_applied"}, - ) - fake_transport.add_json( - "PUT", - "/core/v2/items/101/vas/", - {"success": True, "status": "v2_applied"}, - ) - fake_transport.add_json( - "PUT", - "/promotion/v1/items/services/bbip/orders/create", - {"items": [{"itemId": 101, "success": True, "status": "created", "orderId": "ord-1"}]}, - ) - fake_transport.add_json( - "POST", - "/trx-promo/1/apply", - {"success": {"items": [{"itemID": 101, "success": True}]}}, - ) - fake_transport.add_json( - "POST", - "/trx-promo/1/cancel", - {"success": {"items": [{"itemID": 101, "success": True}]}}, - ) - fake_transport.add_json( - "POST", - "/cpxpromo/1/setAuto", - {"items": [{"itemID": 101, "success": True, "status": "auto"}]}, - ) - fake_transport.add_json( - "POST", - "/cpxpromo/1/setManual", - {"items": [{"itemID": 101, "success": True, "status": "manual"}]}, - ) - fake_transport.add_json( - "POST", - "/cpxpromo/1/remove", - {"items": [{"itemID": 101, "success": True, "status": "removed"}]}, - ) - - transport = fake_transport.build() - ad_promotion = AdPromotion(transport, resource_id=101, user_id=7) - bbip = BbipPromotion(transport, resource_id=101) - trx = TrxPromotion(transport, resource_id=101) - pricing = TargetActionPricing(transport, resource_id=101) - - previews = [ - ad_promotion.apply_vas(codes=["xl"], dry_run=True), - ad_promotion.apply_vas_package(package_code="turbo", dry_run=True), - ad_promotion.apply_vas_direct(codes=["highlight"], dry_run=True), - bbip.create_order( - items=[BbipOrderItem(item_id=101, duration=7, price=1000, old_price=1200)], - dry_run=True, - ), - trx.apply( - items=[TrxPromotionApplyItem(item_id=101, commission=1500, date_from="2026-04-18")], - dry_run=True, - ), - trx.delete(dry_run=True), - pricing.update_auto(action_type_id=5, budget_penny=8000, budget_type="7d", dry_run=True), - pricing.update_manual( - action_type_id=5, - bid_penny=1500, - limit_penny=15000, - dry_run=True, - ), - pricing.delete(dry_run=True), - ] - - assert fake_transport.requests == [] - assert [result.status for result in previews] == ["preview"] * len(previews) - - applied = [ - ad_promotion.apply_vas(codes=["xl"]), - ad_promotion.apply_vas_package(package_code="turbo"), - ad_promotion.apply_vas_direct(codes=["highlight"]), - bbip.create_order( - items=[BbipOrderItem(item_id=101, duration=7, price=1000, old_price=1200)] - ), - trx.apply( - items=[TrxPromotionApplyItem(item_id=101, commission=1500, date_from="2026-04-18")] - ), - trx.delete(), - pricing.update_auto(action_type_id=5, budget_penny=8000, budget_type="7d"), - pricing.update_manual(action_type_id=5, bid_penny=1500, limit_penny=15000), - pricing.delete(), - ] - - assert [result.request_payload for result in applied] == [ - result.request_payload for result in previews - ] - assert all(result.applied is True for result in applied) - assert fake_transport.last( - method="PUT", path="/core/v1/accounts/7/items/101/vas" - ).json_body == {"codes": ["xl"]} - assert fake_transport.last( - method="PUT", path="/promotion/v1/items/services/bbip/orders/create" - ).json_body == {"items": [{"itemId": 101, "duration": 7, "price": 1000, "oldPrice": 1200}]} - assert applied[3].to_dict() == { - "action": "create_order", - "target": {"item_ids": [101]}, - "status": "created", - "applied": True, - "request_payload": { - "items": [{"itemId": 101, "duration": 7, "price": 1000, "oldPrice": 1200}] - }, - "warnings": [], - "upstream_reference": "ord-1", - "details": { - "items": [{"item_id": 101, "success": True, "status": "created", "message": None}] - }, - } - - -@pytest.mark.parametrize( - ("status_code", "error_cls"), - [ - (400, ValidationError), - (403, AuthorizationError), - (409, ConflictError), - (429, RateLimitError), - (405, UnsupportedOperationError), - (418, UpstreamApiError), - ], -) -def test_mock_transport_maps_typed_errors_for_public_calls( - fake_transport: FakeTransport, - status_code: int, - error_cls: type[Exception], -) -> None: - fake_transport.add( - "GET", - "/core/v1/accounts/self", - httpx.Response( - status_code, - json={ - "message": "request failed", - "error": "E_TEST", - "client_secret": "super-secret", - }, - headers={"Authorization": "Bearer secret-token"}, - ), - ) - transport = fake_transport.build(retry_policy=RetryPolicy(max_attempts=1)) - - with pytest.raises(error_cls) as error: - Account(transport).get_self() - - assert "accounts.get_self" in str(error.value) - assert error.value.metadata == {"method": "GET", "path": "/core/v1/accounts/self"} - assert error.value.payload["client_secret"] == "***" - assert error.value.headers["authorization"] == "***" - - -def test_mock_transport_pagination_is_lazy_and_propagates_later_page_errors() -> None: - fake_transport = FakeTransport() - fake_transport.add( - "GET", - "/core/v1/items", - json_response( - { - "items": [{"id": 101, "title": "Смартфон"}, {"id": 102, "title": "Ноутбук"}], - "total": 4, - } - ), - httpx.Response(429, json={"message": "page 2 failed"}, headers={"retry-after": "1"}), - ) - transport = fake_transport.build(retry_policy=RetryPolicy(max_attempts=1)) - items = Ad(transport, user_id=7).list(limit=2) - - assert fake_transport.count(method="GET", path="/core/v1/items") == 1 - assert items.items[0].id == 101 - assert fake_transport.count(method="GET", path="/core/v1/items") == 1 - - with pytest.raises(RateLimitError, match="page 2 failed"): - _ = items.items[2] - - assert fake_transport.count(method="GET", path="/core/v1/items") == 2 - assert items.items[1].id == 102 - assert fake_transport.count(method="GET", path="/core/v1/items") == 2 diff --git a/tests/test_stage11_realty_ratings_tariffs.py b/tests/test_stage11_realty_ratings_tariffs.py deleted file mode 100644 index 53ac4d6..0000000 --- a/tests/test_stage11_realty_ratings_tariffs.py +++ /dev/null @@ -1,231 +0,0 @@ -from __future__ import annotations - -import json - -import httpx - -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts -from avito.ratings import RatingProfile, Review, ReviewAnswer -from avito.ratings.models import ReviewsQuery -from avito.realty import RealtyAnalyticsReport, RealtyBooking, RealtyListing, RealtyPricing -from avito.realty.models import ( - RealtyBaseParamsUpdateRequest, - RealtyBookingsUpdateRequest, - RealtyInterval, - RealtyIntervalsRequest, - RealtyPricePeriod, - RealtyPricesUpdateRequest, -) -from avito.tariffs import Tariff - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_realty_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 == "/core/v1/accounts/10/items/20/bookings": - assert payload == {"blockedDates": ["2026-04-18"]} - return httpx.Response(200, json={"result": "success"}) - if path == "/realty/v1/accounts/10/items/20/bookings": - assert request.url.params["date_start"] == "2026-05-01" - assert request.url.params["date_end"] == "2026-05-05" - assert request.url.params["with_unpaid"] == "true" - return httpx.Response( - 200, - json={ - "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, - }, - } - ] - }, - ) - if path == "/realty/v1/accounts/10/items/20/prices": - assert payload == {"periods": [{"dateFrom": "2026-05-01", "price": 5000}]} - return httpx.Response(200, json={"result": "success"}) - if path == "/realty/v1/items/intervals": - assert payload == { - "itemId": 20, - "intervals": [{"date": "2026-05-01", "available": True}], - } - return httpx.Response(200, json={"result": "success"}) - if path == "/realty/v1/items/20/base": - assert payload == {"minStayDays": 2} - return httpx.Response(200, json={"result": "success"}) - if path == "/realty/v1/marketPriceCorrespondence/20/5000000": - return httpx.Response(200, json={"correspondence": "normal"}) - assert path == "/realty/v1/report/create/20" - return httpx.Response( - 200, - json={"success": {"success": {"reportLink": "https://example.com/realty-report/20"}}}, - ) - - transport = make_transport(httpx.MockTransport(handler)) - booking = RealtyBooking(transport, resource_id="20", user_id="10") - pricing = RealtyPricing(transport, resource_id="20", user_id="10") - listing = RealtyListing(transport, resource_id="20") - analytics = RealtyAnalyticsReport(transport, resource_id="20") - - updated_bookings = booking.update_bookings_info( - request=RealtyBookingsUpdateRequest(blocked_dates=["2026-04-18"]) - ) - bookings = booking.list_realty_bookings( - date_start="2026-05-01", - date_end="2026-05-05", - with_unpaid=True, - ) - updated_prices = pricing.update_realty_prices( - request=RealtyPricesUpdateRequest( - periods=[RealtyPricePeriod(date_from="2026-05-01", price=5000)] - ) - ) - intervals = listing.get_intervals( - request=RealtyIntervalsRequest( - item_id=20, - intervals=[RealtyInterval(date="2026-05-01", available=True)], - ) - ) - base = listing.update_base_params(request=RealtyBaseParamsUpdateRequest(min_stay_days=2)) - market = analytics.get_market_price_correspondence(price=5000000) - report = analytics.get_report_for_classified() - - assert updated_bookings.success is True - assert bookings.items[0].contact is not None - assert bookings.items[0].contact.name == "Иван" - assert bookings.items[0].contact.phone == "9997770000" - assert bookings.items[0].safe_deposit is not None - assert updated_prices.status == "success" - assert intervals.success is True - assert base.success is True - assert market.correspondence == "normal" - assert report.report_link == "https://example.com/realty-report/20" - - -def test_ratings_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - if path == "/ratings/v1/answers": - assert json.loads(request.content.decode()) == { - "reviewId": 123, - "text": "Спасибо за отзыв", - } - return httpx.Response(200, json={"id": 456, "createdAt": 1713427200}) - if path == "/ratings/v1/answers/456": - return httpx.Response(200, json={"success": True}) - if path == "/ratings/v1/info": - return httpx.Response( - 200, - json={ - "isEnabled": True, - "rating": {"score": 4.7, "reviewsCount": 25, "reviewsWithScoreCount": 20}, - }, - ) - assert path == "/ratings/v1/reviews" - assert request.url.params["page"] == "2" - return httpx.Response( - 200, - json={ - "total": 25, - "reviews": [ - { - "id": 123, - "score": 5, - "stage": "done", - "text": "Все отлично", - "createdAt": 1713427200, - "canAnswer": True, - "usedInScore": True, - } - ], - }, - ) - - transport = make_transport(httpx.MockTransport(handler)) - answer = ReviewAnswer(transport, resource_id="456") - profile = RatingProfile(transport) - review = Review(transport) - - created = answer.create(review_id=123, text="Спасибо за отзыв") - deleted = answer.delete() - info = profile.get() - reviews = review.list(query=ReviewsQuery(page=2)) - - assert created.answer_id == "456" - assert deleted.success is True - assert info.score == 4.7 - assert reviews.total == 25 - assert reviews.items[0].text == "Все отлично" - - -def test_tariff_flow() -> None: - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/tariff/info/1" - return httpx.Response( - 200, - json={ - "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}, - }, - }, - ) - - tariff = Tariff(make_transport(httpx.MockTransport(handler))) - - info = tariff.get_tariff_info() - - assert info.current is not None - assert info.current.level == "Тариф Максимальный" - assert info.current.packages_count == 2 - assert info.current.price == 1990 - assert info.scheduled is not None - assert info.scheduled.is_active is False diff --git a/tests/test_stage12_release_gate.py b/tests/test_stage12_release_gate.py deleted file mode 100644 index 53bca49..0000000 --- a/tests/test_stage12_release_gate.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -import httpx - -from avito import AvitoClient -from avito.auth import AlternateTokenClient, AuthProvider, TokenClient -from avito.auth.settings import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport - - -def test_debug_info_does_not_expose_secrets() -> None: - settings = AvitoSettings( - auth=AuthSettings(client_id="client-id", client_secret="super-secret"), - ) - - client = AvitoClient(settings) - info = client.debug_info() - - assert info.base_url == "https://api.avito.ru" - assert info.user_id is None - assert info.requires_auth is True - assert info.retry_max_attempts == settings.retry_policy.max_attempts - assert "secret" not in repr(info).lower() - client.close() - - -def test_client_context_manager_closes_transport_and_auth_clients() -> None: - transport_http_client = httpx.Client() - token_http_client = httpx.Client() - alternate_http_client = httpx.Client() - autoteka_http_client = httpx.Client() - - settings = AvitoSettings( - auth=AuthSettings(client_id="client-id", client_secret="client-secret"), - ) - auth_provider = AuthProvider( - settings.auth, - token_client=TokenClient(settings.auth, client=token_http_client), - alternate_token_client=AlternateTokenClient( - settings.auth, client=alternate_http_client - ), - autoteka_token_client=TokenClient(settings.auth, client=autoteka_http_client), - ) - client = AvitoClient(settings) - client.transport = Transport( - settings, auth_provider=auth_provider, client=transport_http_client - ) - client.auth_provider = auth_provider - - with client as managed_client: - assert managed_client.debug_info().requires_auth is True - - assert transport_http_client.is_closed is True - assert token_http_client.is_closed is True - assert alternate_http_client.is_closed is True - assert autoteka_http_client.is_closed is True diff --git a/tests/test_stage4_accounts_ads.py b/tests/test_stage4_accounts_ads.py deleted file mode 100644 index d2338b1..0000000 --- a/tests/test_stage4_accounts_ads.py +++ /dev/null @@ -1,373 +0,0 @@ -from __future__ import annotations - -import json - -import httpx - -from avito.accounts import Account, AccountHierarchy -from avito.ads import Ad, AdPromotion, AdStats, AutoloadArchive, AutoloadProfile, AutoloadReport -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_accounts_domain_maps_profile_balance_and_operations() -> None: - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/core/v1/accounts/self": - return httpx.Response( - 200, json={"id": 7, "name": "Иван", "email": "user@example.com", "phone": "+7999"} - ) - if request.url.path == "/core/v1/accounts/7/balance/": - return httpx.Response( - 200, - json={"user_id": 7, "balance": {"real": 150.5, "bonus": 20.0, "currency": "RUB"}}, - ) - assert request.url.path == "/core/v1/accounts/operations_history/" - assert json.loads(request.content.decode()) == {"dateFrom": "2025-01-01", "limit": 2} - return httpx.Response( - 200, - json={ - "total": 1, - "operations": [ - { - "id": "op-1", - "created_at": "2025-01-02T12:00:00Z", - "amount": 120.0, - "type": "payment", - "status": "done", - } - ], - }, - ) - - transport = make_transport(httpx.MockTransport(handler)) - account = Account(transport, resource_id=7, user_id=7) - - profile = account.get_self() - balance = account.get_balance() - history = account.get_operations_history(date_from="2025-01-01", limit=2) - - assert profile.id == 7 - assert balance.total == 170.5 - assert history.total == 1 - assert history.operations[0].operation_type == "payment" - - -def test_account_hierarchy_domain_maps_employees_phones_and_items() -> None: - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/checkAhUserV1": - return httpx.Response(200, json={"user_id": 7, "is_active": True, "role": "manager"}) - if request.url.path == "/getEmployeesV1": - return httpx.Response( - 200, - json={"employees": [{"employee_id": 10, "user_id": 7, "name": "Пётр"}], "total": 1}, - ) - if request.url.path == "/listCompanyPhonesV1": - return httpx.Response( - 200, json={"phones": [{"id": 1, "phone": "+7000", "comment": "Основной"}]} - ) - if request.url.path == "/linkItemsV1": - assert json.loads(request.content.decode()) == {"employeeId": 10, "itemIds": [1, 2]} - return httpx.Response(200, json={"success": True, "message": "linked"}) - assert request.url.path == "/listItemsByEmployeeIdV1" - assert json.loads(request.content.decode()) == {"employeeId": 10, "limit": 5} - return httpx.Response( - 200, - json={ - "items": [{"item_id": 1, "title": "Объявление", "status": "active", "price": 99}], - "total": 1, - }, - ) - - hierarchy = AccountHierarchy( - make_transport(httpx.MockTransport(handler)), resource_id=7, user_id=7 - ) - - status = hierarchy.get_status() - employees = hierarchy.list_employees() - phones = hierarchy.list_company_phones() - linked = hierarchy.link_items(employee_id=10, item_ids=[1, 2]) - items = hierarchy.list_items_by_employee(employee_id=10, limit=5) - - 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 items.items[0].title == "Объявление" - - -def test_ads_list_uses_lazy_pagination_with_list_like_items() -> None: - seen_offsets: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/core/v1/items" - assert request.url.params["user_id"] == "7" - assert request.url.params["status"] == "active" - assert request.url.params["limit"] == "2" - - offset = request.url.params["offset"] - seen_offsets.append(offset) - page_items = { - "0": [{"id": 101, "title": "Смартфон"}, {"id": 102, "title": "Ноутбук"}], - "2": [{"id": 103, "title": "Планшет"}, {"id": 104, "title": "Наушники"}], - "4": [{"id": 105, "title": "Камера"}], - } - return httpx.Response(200, json={"items": page_items[offset], "total": 5}) - - transport = make_transport(httpx.MockTransport(handler)) - ad = Ad(transport, user_id=7) - - items = ad.list(status="active", limit=2) - - assert seen_offsets == ["0"] - assert items.items.loaded_count == 2 - assert items.items.is_materialized is False - assert items.items[0].id == 101 - assert seen_offsets == ["0"] - assert items.items[3].id == 104 - assert seen_offsets == ["0", "2"] - assert items.items[1].id == 102 - assert seen_offsets == ["0", "2"] - assert [item.title for item in items.items[:3]] == ["Смартфон", "Ноутбук", "Планшет"] - assert seen_offsets == ["0", "2"] - assert items.items.loaded_count == 4 - assert items.items.is_materialized is False - assert [item.title for item in items.items.materialize()] == [ - "Смартфон", - "Ноутбук", - "Планшет", - "Наушники", - "Камера", - ] - assert len(items.items) == 5 - assert [item.title for item in items.items] == [ - "Смартфон", - "Ноутбук", - "Планшет", - "Наушники", - "Камера", - ] - assert items.items.is_materialized is True - assert seen_offsets == ["0", "2", "4"] - - -def test_ads_domain_covers_item_stats_spendings_and_promotion() -> None: - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/core/v1/accounts/7/items/101/": - return httpx.Response( - 200, - json={ - "id": 101, - "user_id": 7, - "title": "Смартфон", - "price": 1000, - "status": "active", - }, - ) - if request.url.path == "/core/v1/items": - assert request.url.params["user_id"] == "7" - assert request.url.params["status"] == "active" - return httpx.Response( - 200, json={"items": [{"id": 101, "title": "Смартфон"}], "total": 1} - ) - if request.url.path == "/core/v1/items/101/update_price": - assert json.loads(request.content.decode()) == {"price": 1500} - return httpx.Response(200, json={"item_id": 101, "price": 1500, "status": "updated"}) - if request.url.path == "/stats/v1/accounts/7/items": - body = json.loads(request.content.decode()) - assert body["itemIds"] == [101] - return httpx.Response( - 200, json={"items": [{"item_id": 101, "views": 45, "contacts": 5, "favorites": 2}]} - ) - if request.url.path == "/core/v1/accounts/7/calls/stats/": - return httpx.Response( - 200, - json={ - "items": [{"item_id": 101, "calls": 3, "answered_calls": 2, "missed_calls": 1}] - }, - ) - if request.url.path == "/stats/v2/accounts/7/items": - return httpx.Response( - 200, json={"period": "month", "items": [{"item_id": 101, "views": 100}]} - ) - if request.url.path == "/stats/v2/accounts/7/spendings": - return httpx.Response( - 200, json={"items": [{"item_id": 101, "amount": 77.5, "service": "xl"}]} - ) - if request.url.path == "/core/v1/accounts/7/vas/prices": - assert json.loads(request.content.decode()) == {"itemIds": [101], "locationId": 213} - return httpx.Response( - 200, - json={"services": [{"code": "xl", "title": "XL", "price": 50, "available": True}]}, - ) - if request.url.path == "/core/v1/accounts/7/items/101/vas": - assert json.loads(request.content.decode()) == {"codes": ["xl"]} - return httpx.Response(200, json={"success": True, "status": "applied"}) - if request.url.path == "/core/v2/accounts/7/items/101/vas_packages": - assert json.loads(request.content.decode()) == {"packageCode": "turbo"} - return httpx.Response(200, json={"success": True, "status": "package_applied"}) - assert request.url.path == "/core/v2/items/101/vas/" - assert json.loads(request.content.decode()) == {"codes": ["highlight"]} - return httpx.Response(200, json={"success": True, "status": "v2_applied"}) - - transport = make_transport(httpx.MockTransport(handler)) - ad = Ad(transport, resource_id=101, user_id=7) - stats = AdStats(transport, resource_id=101, user_id=7) - promotion = AdPromotion(transport, resource_id=101, user_id=7) - - item = ad.get() - items = ad.list(status="active") - price = ad.update_price(price=1500) - item_stats = ad.get_stats() - calls = stats.get_calls_stats() - analytics = stats.get_item_analytics() - spendings = stats.get_account_spendings() - vas_prices = promotion.get_vas_prices(item_ids=[101], location_id=213) - vas_apply = promotion.apply_vas(codes=["xl"]) - package_apply = promotion.apply_vas_package(package_code="turbo") - vas_v2_apply = promotion.apply_vas_direct(codes=["highlight"]) - - assert item.title == "Смартфон" - assert items.total == 1 - assert price.price == 1500 - assert item_stats.items[0].views == 45 - assert calls.items[0].answered_calls == 2 - assert analytics.period == "month" - assert spendings.total == 77.5 - assert vas_prices.items[0].code == "xl" - assert vas_apply.applied is True - assert package_apply.status == "package_applied" - assert vas_v2_apply.status == "v2_applied" - - -def test_autoload_domains_cover_profile_report_and_archive_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - if path == "/autoload/v2/profile" and request.method == "GET": - return httpx.Response( - 200, json={"user_id": 7, "is_enabled": True, "upload_url": "https://upload"} - ) - if path == "/autoload/v2/profile" and request.method == "POST": - assert json.loads(request.content.decode()) == { - "isEnabled": True, - "email": "feed@example.com", - } - return httpx.Response(200, json={"success": True, "message": "saved"}) - if path == "/autoload/v1/upload": - assert json.loads(request.content.decode()) == {"url": "https://example.com/feed.xml"} - return httpx.Response(200, json={"success": True, "report_id": 501}) - if path == "/autoload/v1/user-docs/tree": - return httpx.Response( - 200, json={"tree": [{"slug": "transport", "title": "Транспорт", "children": []}]} - ) - if path == "/autoload/v1/user-docs/node/cars/fields": - return httpx.Response( - 200, - json={ - "fields": [ - {"slug": "brand", "title": "Марка", "type": "string", "required": True} - ] - }, - ) - if path == "/autoload/v2/reports": - return httpx.Response( - 200, json={"reports": [{"report_id": 501, "status": "done"}], "total": 1} - ) - if path == "/autoload/v3/reports/501": - return httpx.Response( - 200, - json={"report_id": 501, "status": "done", "errors_count": 0, "warnings_count": 1}, - ) - if path == "/autoload/v3/reports/last_completed_report": - return httpx.Response(200, json={"report_id": 500, "status": "done"}) - if path == "/autoload/v2/reports/501/items": - return httpx.Response( - 200, - json={ - "items": [ - {"item_id": 101, "avito_id": 9001, "status": "active", "title": "Авто"} - ], - "total": 1, - }, - ) - if path == "/autoload/v2/reports/501/items/fees": - return httpx.Response( - 200, json={"items": [{"item_id": 101, "amount": 42.0, "service": "xl"}]} - ) - if path == "/autoload/v2/items/ad_ids": - assert request.url.params["avito_ids"] == "9001,9002" - return httpx.Response(200, json={"mappings": [{"ad_id": 1, "avito_id": 9001}]}) - if path == "/autoload/v2/items/avito_ids": - assert request.url.params["ad_ids"] == "1,2" - return httpx.Response(200, json={"mappings": [{"ad_id": 1, "avito_id": 9001}]}) - if path == "/autoload/v2/reports/items": - assert request.url.params["item_ids"] == "101" - return httpx.Response( - 200, json={"items": [{"item_id": 101, "avito_id": 9001, "status": "active"}]} - ) - if path == "/autoload/v1/profile" and request.method == "GET": - return httpx.Response(200, json={"user_id": 7, "is_enabled": False}) - if path == "/autoload/v1/profile" and request.method == "POST": - return httpx.Response(200, json={"success": True, "message": "legacy_saved"}) - if path == "/autoload/v2/reports/last_completed_report": - return httpx.Response(200, json={"report_id": 401, "status": "legacy_done"}) - assert path == "/autoload/v2/reports/401" - return httpx.Response(200, json={"report_id": 401, "status": "legacy_done"}) - - transport = make_transport(httpx.MockTransport(handler)) - profile = AutoloadProfile(transport) - report = AutoloadReport(transport, resource_id=501) - archive = AutoloadArchive(transport, resource_id=401) - - current_profile = profile.get() - saved_profile = profile.save(is_enabled=True, email="feed@example.com") - upload = profile.upload_by_url(url="https://example.com/feed.xml") - tree = profile.get_tree() - fields = profile.get_node_fields(node_slug="cars") - reports = report.list() - report_details = report.get() - last_report = report.get_last_completed() - report_items = report.get_items() - report_fees = report.get_fees() - ad_ids = report.get_ad_ids_by_avito_ids(avito_ids=[9001, 9002]) - avito_ids = report.get_avito_ids_by_ad_ids(ad_ids=[1, 2]) - items_info = report.get_items_info(item_ids=[101]) - legacy_profile = archive.get_profile() - legacy_saved = archive.save_profile(email="legacy@example.com") - legacy_last = archive.get_last_completed_report() - legacy_report = archive.get_report() - - assert current_profile.is_enabled is True - assert saved_profile.success is True - assert upload.report_id == 501 - assert tree.items[0].slug == "transport" - assert fields.items[0].required is True - assert reports.total == 1 - assert report_details.warnings_count == 1 - assert last_report.report_id == 500 - assert report_items.items[0].avito_id == 9001 - assert report_fees.total == 42.0 - assert ad_ids.mappings[0] == (1, 9001) - assert avito_ids.mappings[0] == (1, 9001) - assert items_info.items[0].item_id == 101 - assert legacy_profile.is_enabled is False - assert legacy_saved.message == "legacy_saved" - assert legacy_last.status == "legacy_done" - assert legacy_report.report_id == 401 diff --git a/tests/test_stage4_datetime_domain_inputs.py b/tests/test_stage4_datetime_domain_inputs.py deleted file mode 100644 index 2405a52..0000000 --- a/tests/test_stage4_datetime_domain_inputs.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import annotations - -import json -from datetime import datetime - -import httpx - -from avito.accounts import Account -from avito.ads import AdStats -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_account_operations_history_accepts_datetime_filters() -> None: - date_from = datetime(2025, 1, 1, 10, 30, 0) - date_to = datetime(2025, 1, 2, 18, 45, 0) - - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/core/v1/accounts/operations_history/" - assert json.loads(request.content.decode()) == { - "dateFrom": "2025-01-01T10:30:00", - "dateTo": "2025-01-02T18:45:00", - "limit": 5, - "offset": 10, - } - return httpx.Response(200, json={"operations": [], "total": 0}) - - transport = make_transport(httpx.MockTransport(handler)) - - result = Account(transport, user_id=7).get_operations_history( - date_from=date_from, - date_to=date_to, - limit=5, - offset=10, - ) - - assert result == [] - - -def test_ad_stats_accept_datetime_filters_and_serialize_isoformat() -> None: - date_from = datetime(2025, 1, 1, 0, 0, 0) - date_to = datetime(2025, 1, 31, 23, 59, 59) - - def handler(request: httpx.Request) -> httpx.Response: - body = json.loads(request.content.decode()) - assert body["itemIds"] == [101] - assert body["dateFrom"] == "2025-01-01T00:00:00" - assert body["dateTo"] == "2025-01-31T23:59:59" - - if request.url.path == "/core/v1/accounts/7/calls/stats/": - return httpx.Response(200, json={"items": []}) - if request.url.path == "/stats/v1/accounts/7/items": - return httpx.Response(200, json={"items": []}) - if request.url.path == "/stats/v2/accounts/7/items": - return httpx.Response(200, json={"items": [], "period": "month"}) - assert request.url.path == "/stats/v2/accounts/7/spendings" - return httpx.Response(200, json={"items": [], "total": 0}) - - transport = make_transport(httpx.MockTransport(handler)) - stats = AdStats(transport, item_id=101, user_id=7) - - calls = stats.get_calls_stats(date_from=date_from, date_to=date_to) - item_stats = stats.get_item_stats(date_from=date_from, date_to=date_to) - analytics = stats.get_item_analytics(date_from=date_from, date_to=date_to) - spendings = stats.get_account_spendings(date_from=date_from, date_to=date_to) - - assert calls.items == [] - assert item_stats.items == [] - assert analytics.period == "month" - assert spendings.total == 0 diff --git a/tests/test_stage4_promotion_write_contract.py b/tests/test_stage4_promotion_write_contract.py deleted file mode 100644 index 84c144a..0000000 --- a/tests/test_stage4_promotion_write_contract.py +++ /dev/null @@ -1,337 +0,0 @@ -from __future__ import annotations - -import json -from collections.abc import Callable - -import httpx -import pytest - -from avito.ads import AdPromotion -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport, ValidationError -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts -from avito.promotion import BbipOrderItem, BbipPromotion, TargetActionPricing, TrxPromotion -from avito.promotion.models import TrxPromotionApplyItem - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def _json(request: httpx.Request) -> dict[str, object]: - return json.loads(request.content.decode()) if request.content else {} - - -def test_write_methods_dry_run_skip_transport_and_return_preview() -> None: - calls: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - calls.append(request.url.path) - raise AssertionError("dry_run must not call transport") - - transport = make_transport(httpx.MockTransport(handler)) - ad_promotion = AdPromotion(transport, resource_id=101, user_id=7) - bbip = BbipPromotion(transport, resource_id=101) - trx = TrxPromotion(transport, resource_id=101) - pricing = TargetActionPricing(transport, resource_id=101) - - results = [ - ad_promotion.apply_vas(codes=["xl"], dry_run=True), - ad_promotion.apply_vas_package(package_code="turbo", dry_run=True), - ad_promotion.apply_vas_direct(codes=["highlight"], dry_run=True), - bbip.create_order( - items=[BbipOrderItem(item_id=101, duration=7, price=1000, old_price=1200)], - dry_run=True, - ), - trx.apply( - items=[TrxPromotionApplyItem(item_id=101, commission=1500, date_from="2026-04-18")], - dry_run=True, - ), - trx.delete(dry_run=True), - pricing.update_auto( - action_type_id=5, - budget_penny=8000, - budget_type="7d", - dry_run=True, - ), - pricing.update_manual( - action_type_id=5, - bid_penny=1500, - limit_penny=15000, - dry_run=True, - ), - pricing.delete(dry_run=True), - ] - - assert calls == [] - assert [result.status for result in results] == ["preview"] * len(results) - assert all(result.applied is False for result in results) - assert all(result.details == {"validated": True} for result in results) - assert all(result.request_payload for result in results) - assert results[0].to_dict() == { - "action": "apply_vas", - "target": {"item_id": 101, "user_id": 7}, - "status": "preview", - "applied": False, - "request_payload": {"codes": ["xl"]}, - "warnings": [], - "upstream_reference": None, - "details": {"validated": True}, - } - - -def test_write_methods_dry_run_and_apply_build_identical_payloads() -> None: - seen: list[tuple[str, dict[str, object]]] = [] - - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - payload = _json(request) - seen.append((path, payload)) - - if path == "/core/v1/accounts/7/items/101/vas": - return httpx.Response(200, json={"success": True, "status": "applied"}) - if path == "/core/v2/accounts/7/items/101/vas_packages": - return httpx.Response(200, json={"success": True, "status": "package_applied"}) - if path == "/core/v2/items/101/vas/": - return httpx.Response(200, json={"success": True, "status": "v2_applied"}) - if path == "/promotion/v1/items/services/bbip/orders/create": - return httpx.Response( - 200, - json={ - "items": [ - {"itemId": 101, "success": True, "status": "created", "orderId": "ord-1"} - ] - }, - ) - if path == "/trx-promo/1/apply": - return httpx.Response( - 200, json={"success": {"items": [{"itemID": 101, "success": True}]}} - ) - if path == "/trx-promo/1/cancel": - return httpx.Response( - 200, json={"success": {"items": [{"itemID": 101, "success": True}]}} - ) - if path == "/cpxpromo/1/setAuto": - return httpx.Response( - 200, json={"items": [{"itemID": 101, "success": True, "status": "auto"}]} - ) - if path == "/cpxpromo/1/setManual": - return httpx.Response( - 200, json={"items": [{"itemID": 101, "success": True, "status": "manual"}]} - ) - assert path == "/cpxpromo/1/remove" - return httpx.Response( - 200, json={"items": [{"itemID": 101, "success": True, "status": "removed"}]} - ) - - transport = make_transport(httpx.MockTransport(handler)) - ad_promotion = AdPromotion(transport, resource_id=101, user_id=7) - bbip = BbipPromotion(transport, resource_id=101) - trx = TrxPromotion(transport, resource_id=101) - pricing = TargetActionPricing(transport, resource_id=101) - - vas_preview = ad_promotion.apply_vas(codes=["xl"], dry_run=True) - package_preview = ad_promotion.apply_vas_package(package_code="turbo", dry_run=True) - vas_v2_preview = ad_promotion.apply_vas_direct(codes=["highlight"], dry_run=True) - bbip_preview = bbip.create_order( - items=[BbipOrderItem(item_id=101, duration=7, price=1000, old_price=1200)], - dry_run=True, - ) - trx_apply_preview = trx.apply( - items=[TrxPromotionApplyItem(item_id=101, commission=1500, date_from="2026-04-18")], - dry_run=True, - ) - trx_delete_preview = trx.delete(dry_run=True) - auto_preview = pricing.update_auto( - action_type_id=5, - budget_penny=8000, - budget_type="7d", - dry_run=True, - ) - manual_preview = pricing.update_manual( - action_type_id=5, - bid_penny=1500, - limit_penny=15000, - dry_run=True, - ) - delete_preview = pricing.delete(dry_run=True) - - vas_apply = ad_promotion.apply_vas(codes=["xl"]) - package_apply = ad_promotion.apply_vas_package(package_code="turbo") - vas_v2_apply = ad_promotion.apply_vas_direct(codes=["highlight"]) - bbip_apply = bbip.create_order( - items=[BbipOrderItem(item_id=101, duration=7, price=1000, old_price=1200)] - ) - trx_apply = trx.apply( - items=[TrxPromotionApplyItem(item_id=101, commission=1500, date_from="2026-04-18")] - ) - trx_delete = trx.delete() - auto_apply = pricing.update_auto(action_type_id=5, budget_penny=8000, budget_type="7d") - manual_apply = pricing.update_manual(action_type_id=5, bid_penny=1500, limit_penny=15000) - delete_apply = pricing.delete() - - assert seen == [ - ("/core/v1/accounts/7/items/101/vas", vas_preview.request_payload), - ("/core/v2/accounts/7/items/101/vas_packages", package_preview.request_payload), - ("/core/v2/items/101/vas/", vas_v2_preview.request_payload), - ("/promotion/v1/items/services/bbip/orders/create", bbip_preview.request_payload), - ("/trx-promo/1/apply", trx_apply_preview.request_payload), - ("/trx-promo/1/cancel", trx_delete_preview.request_payload), - ("/cpxpromo/1/setAuto", auto_preview.request_payload), - ("/cpxpromo/1/setManual", manual_preview.request_payload), - ("/cpxpromo/1/remove", delete_preview.request_payload), - ] - assert vas_apply.request_payload == vas_preview.request_payload - assert package_apply.request_payload == package_preview.request_payload - assert vas_v2_apply.request_payload == vas_v2_preview.request_payload - assert bbip_apply.request_payload == bbip_preview.request_payload - assert trx_apply.request_payload == trx_apply_preview.request_payload - assert trx_delete.request_payload == trx_delete_preview.request_payload - assert auto_apply.request_payload == auto_preview.request_payload - assert manual_apply.request_payload == manual_preview.request_payload - assert delete_apply.request_payload == delete_preview.request_payload - assert bbip_apply.upstream_reference == "ord-1" - assert vas_apply.status == "applied" - assert auto_apply.status == "auto" - assert bbip_apply.to_dict() == { - "action": "create_order", - "target": {"item_ids": [101]}, - "status": "created", - "applied": True, - "request_payload": { - "items": [{"itemId": 101, "duration": 7, "price": 1000, "oldPrice": 1200}] - }, - "warnings": [], - "upstream_reference": "ord-1", - "details": { - "items": [{"item_id": 101, "success": True, "status": "created", "message": None}] - }, - } - - -@pytest.mark.parametrize( - ("call", "expected"), - [ - ( - lambda resource: resource.apply_vas(codes=[], dry_run=True), - "`codes` must contain at least one item.", - ), - ( - lambda resource: resource.apply_vas_package(package_code=" ", dry_run=True), - "`package_code` must be a non-empty string.", - ), - ( - lambda resource: resource.apply_vas(codes=["ok", " "], dry_run=True), - r"`codes\[1\]` must be a non-empty string.", - ), - ( - lambda resource: resource.update_auto( - action_type_id=5, - budget_penny=8000, - budget_type=" ", - dry_run=True, - ), - "`budget_type` must be a non-empty string.", - ), - ], -) -def test_write_validation_happens_before_transport( - call: Callable[[AdPromotion | TargetActionPricing], object], - expected: str, -) -> None: - calls: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - calls.append(request.url.path) - raise AssertionError("validation must fail before transport") - - transport = make_transport(httpx.MockTransport(handler)) - resource = AdPromotion(transport, resource_id=101, user_id=7) - if "budget_type" in expected: - resource = TargetActionPricing(transport, resource_id=101) - - with pytest.raises(ValidationError, match=expected): - call(resource) - - assert calls == [] - - -@pytest.mark.parametrize( - ("call", "expected"), - [ - ( - lambda resource: resource.create_order( - items=[BbipOrderItem(item_id=0, duration=7, price=1000, old_price=1200)], - dry_run=True, - ), - r"`items\[0\]\.item_id` must be a positive integer.", - ), - ( - lambda resource: resource.create_order( - items=[BbipOrderItem(item_id=101, duration=0, price=1000, old_price=1200)], - dry_run=True, - ), - r"`items\[0\]\.duration` must be a positive integer.", - ), - ( - lambda resource: resource.apply( - items=[TrxPromotionApplyItem(item_id=101, commission=0, date_from="2026-04-18")], - dry_run=True, - ), - r"`items\[0\]\.commission` must be a positive integer.", - ), - ( - lambda resource: resource.apply( - items=[TrxPromotionApplyItem(item_id=101, commission=100, date_from=" ")], - dry_run=True, - ), - r"`items\[0\]\.date_from` must be a non-empty string.", - ), - ], -) -def test_nested_write_validation_happens_before_transport( - call: Callable[[BbipPromotion | TrxPromotion], object], - expected: str, -) -> None: - calls: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - calls.append(request.url.path) - raise AssertionError("validation must fail before transport") - - transport = make_transport(httpx.MockTransport(handler)) - resource: BbipPromotion | TrxPromotion - if "date_from" in expected or "commission" in expected: - resource = TrxPromotion(transport, resource_id=101) - else: - resource = BbipPromotion(transport, resource_id=101) - - with pytest.raises(ValidationError, match=expected): - call(resource) - - assert calls == [] - - -def test_write_upstream_validation_error_is_mapped_to_sdk_error() -> None: - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/cpxpromo/1/setManual" - return httpx.Response(422, json={"message": "invalid bid"}) - - transport = make_transport(httpx.MockTransport(handler)) - pricing = TargetActionPricing(transport, resource_id=101) - - with pytest.raises(ValidationError, match="invalid bid"): - pricing.update_manual(action_type_id=5, bid_penny=1500, limit_penny=15000) diff --git a/tests/test_stage5_datetime_model_mapping.py b/tests/test_stage5_datetime_model_mapping.py deleted file mode 100644 index 136c562..0000000 --- a/tests/test_stage5_datetime_model_mapping.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -import json -from datetime import UTC, datetime - -from avito.accounts.mappers import map_operations_history -from avito.ads.mappers import map_autoload_report_details, map_autoload_reports -from avito.messenger.mappers import map_message -from avito.promotion.mappers import map_cpa_auction_bids - - -def test_datetime_fields_are_parsed_into_models_and_serialized_as_iso_strings() -> None: - operations = map_operations_history( - { - "operations": [ - { - "id": "op-1", - "created_at": "2025-01-02T12:00:00Z", - "amount": 120.0, - "type": "payment", - "status": "done", - } - ], - "total": 1, - } - ) - message = map_message( - { - "id": "msg-1", - "chat_id": "chat-1", - "author_id": 7, - "text": "hello", - "created_at": "2025-01-03T08:15:00+03:00", - } - ) - reports = map_autoload_reports( - { - "reports": [ - { - "report_id": 501, - "status": "done", - "created_at": "2025-01-04T09:00:00+03:00", - "finished_at": "2025-01-04T09:10:00+03:00", - } - ] - } - ) - details = map_autoload_report_details( - { - "report_id": 501, - "status": "done", - "created_at": "2025-01-04T09:00:00+03:00", - "finished_at": "2025-01-04T09:10:00+03:00", - "errors_count": 0, - "warnings_count": 1, - } - ) - bids = map_cpa_auction_bids( - { - "items": [ - { - "itemId": 101, - "pricePenny": 1500, - "expirationTime": "2025-01-05T18:30:00Z", - "availablePrices": [{"pricePenny": 1600, "goodness": 9}], - } - ] - } - ) - - assert operations.operations[0].created_at == datetime(2025, 1, 2, 12, 0, tzinfo=UTC) - assert message.created_at == datetime.fromisoformat("2025-01-03T08:15:00+03:00") - assert reports.items[0].created_at == datetime.fromisoformat("2025-01-04T09:00:00+03:00") - assert reports.items[0].finished_at == datetime.fromisoformat("2025-01-04T09:10:00+03:00") - assert details.created_at == datetime.fromisoformat("2025-01-04T09:00:00+03:00") - assert details.finished_at == datetime.fromisoformat("2025-01-04T09:10:00+03:00") - assert bids.items[0].expiration_time == datetime(2025, 1, 5, 18, 30, tzinfo=UTC) - - assert operations.to_dict()["operations"][0]["created_at"] == "2025-01-02T12:00:00+00:00" - assert message.to_dict()["created_at"] == "2025-01-03T08:15:00+03:00" - assert reports.to_dict()["items"][0]["created_at"] == "2025-01-04T09:00:00+03:00" - assert reports.to_dict()["items"][0]["finished_at"] == "2025-01-04T09:10:00+03:00" - assert details.to_dict()["created_at"] == "2025-01-04T09:00:00+03:00" - assert details.to_dict()["finished_at"] == "2025-01-04T09:10:00+03:00" - assert bids.to_dict()["items"][0]["expiration_time"] == "2025-01-05T18:30:00+00:00" - - json.dumps(operations.to_dict()) - json.dumps(message.to_dict()) - json.dumps(reports.to_dict()) - json.dumps(details.to_dict()) - json.dumps(bids.to_dict()) diff --git a/tests/test_stage5_messenger.py b/tests/test_stage5_messenger.py deleted file mode 100644 index 6126a36..0000000 --- a/tests/test_stage5_messenger.py +++ /dev/null @@ -1,223 +0,0 @@ -from __future__ import annotations - -import json - -import httpx - -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts -from avito.messenger import Chat, ChatMedia, ChatMessage, ChatWebhook, SpecialOfferCampaign -from avito.messenger.models import UploadImageFile - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_messenger_chat_and_message_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - if path == "/messenger/v2/accounts/7/chats": - return httpx.Response( - 200, - json={ - "chats": [ - {"id": "chat-1", "user_id": 7, "title": "Покупатель", "unread_count": 2} - ] - }, - ) - if path == "/messenger/v2/accounts/7/chats/chat-1": - return httpx.Response( - 200, - json={ - "id": "chat-1", - "user_id": 7, - "title": "Покупатель", - "last_message": {"text": "Привет"}, - }, - ) - if path == "/messenger/v1/accounts/7/chats/chat-1/read": - return httpx.Response(200, json={"success": True, "status": "read"}) - if path == "/messenger/v2/accounts/7/blacklist": - assert json.loads(request.content.decode()) == {"blacklistedUserId": 99} - return httpx.Response(200, json={"success": True, "status": "blacklisted"}) - if path == "/messenger/v1/accounts/7/chats/chat-1/messages": - assert json.loads(request.content.decode()) == {"message": "Здравствуйте"} - return httpx.Response( - 200, json={"success": True, "message_id": "msg-1", "status": "sent"} - ) - if path == "/messenger/v3/accounts/7/chats/chat-1/messages/": - return httpx.Response( - 200, - json={ - "messages": [ - { - "id": "msg-1", - "chat_id": "chat-1", - "text": "Здравствуйте", - "direction": "out", - } - ], - "total": 1, - }, - ) - assert path == "/messenger/v1/accounts/7/chats/chat-1/messages/msg-1" - return httpx.Response(200, json={"success": True, "status": "deleted"}) - - transport = make_transport(httpx.MockTransport(handler)) - chat = Chat(transport, resource_id="chat-1", user_id=7) - message = ChatMessage(transport, resource_id="msg-1", user_id=7) - - chats = chat.list() - chat_info = chat.get() - mark_read = chat.mark_read() - blacklisted = chat.blacklist(blacklisted_user_id=99) - sent = message.send_message(chat_id="chat-1", message="Здравствуйте") - messages = message.list(chat_id="chat-1") - deleted = message.delete(chat_id="chat-1") - - assert chats.items[0].id == "chat-1" - assert chat_info.last_message_text == "Привет" - assert mark_read.status == "read" - assert blacklisted.status == "blacklisted" - assert sent.message_id == "msg-1" - assert messages.total == 1 - assert deleted.status == "deleted" - - -def test_messenger_media_upload_and_send_image_flow() -> None: - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/messenger/v1/accounts/7/uploadImages": - assert request.headers["Content-Type"].startswith("multipart/form-data") - return httpx.Response( - 200, json={"images": [{"image_id": "img-1", "url": "https://cdn/img-1.jpg"}]} - ) - if request.url.path == "/messenger/v1/accounts/7/getVoiceFiles": - return httpx.Response( - 200, - json={ - "voice_files": [ - {"id": "voice-1", "url": "https://cdn/voice.mp3", "duration": 5} - ] - }, - ) - assert request.url.path == "/messenger/v1/accounts/7/chats/chat-1/messages/image" - assert json.loads(request.content.decode()) == {"imageId": "img-1", "caption": "Фото"} - return httpx.Response( - 200, json={"success": True, "message_id": "msg-img-1", "status": "sent"} - ) - - transport = make_transport(httpx.MockTransport(handler)) - media = ChatMedia(transport, user_id=7) - message = ChatMessage(transport, user_id=7) - - uploaded = media.upload_images( - files=[ - UploadImageFile( - field_name="image", - filename="photo.jpg", - content=b"binary", - content_type="image/jpeg", - ) - ] - ) - voice_files = media.get_voice_files() - sent = message.send_image( - chat_id="chat-1", image_id=uploaded.items[0].image_id or "", caption="Фото" - ) - - assert uploaded.items[0].image_id == "img-1" - assert voice_files.items[0].duration == 5 - assert sent.message_id == "msg-img-1" - - -def test_messenger_webhook_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/messenger/v1/subscriptions": - return httpx.Response( - 200, - json={ - "subscriptions": [ - {"url": "https://example.com/hook", "version": "v2", "status": "active"} - ] - }, - ) - if request.url.path == "/messenger/v1/webhook/unsubscribe": - assert json.loads(request.content.decode()) == {"url": "https://example.com/hook"} - return httpx.Response(200, json={"success": True, "status": "unsubscribed"}) - assert request.url.path == "/messenger/v3/webhook" - assert json.loads(request.content.decode()) == { - "url": "https://example.com/hook", - "secret": "top-secret", - } - return httpx.Response(200, json={"success": True, "status": "subscribed"}) - - webhook = ChatWebhook(make_transport(httpx.MockTransport(handler))) - - subscriptions = webhook.list() - unsubscribed = webhook.unsubscribe(url="https://example.com/hook") - subscribed = webhook.subscribe(url="https://example.com/hook", secret="top-secret") - - assert subscriptions.items[0].status == "active" - assert unsubscribed.status == "unsubscribed" - assert subscribed.status == "subscribed" - - -def test_special_offer_campaign_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 == "/special-offers/v1/available": - assert payload == {"itemIds": [1, 2]} - return httpx.Response( - 200, json={"items": [{"item_id": 1, "title": "Объявление", "is_available": True}]} - ) - if path == "/special-offers/v1/multiCreate": - assert payload == {"itemIds": [1], "message": "Скидка 10%", "discountPercent": 10} - return httpx.Response(200, json={"campaign_id": "camp-1", "status": "draft"}) - if path == "/special-offers/v1/multiConfirm": - assert payload == {"campaignId": "camp-1"} - return httpx.Response(200, json={"success": True, "status": "confirmed"}) - if path == "/special-offers/v1/stats": - assert payload == {"campaignId": "camp-1"} - return httpx.Response( - 200, - json={ - "campaign_id": "camp-1", - "sent_count": 20, - "delivered_count": 18, - "read_count": 10, - }, - ) - assert path == "/special-offers/v1/tariffInfo" - return httpx.Response(200, json={"price": 9.9, "currency": "RUB", "daily_limit": 100}) - - campaign = SpecialOfferCampaign( - make_transport(httpx.MockTransport(handler)), resource_id="camp-1" - ) - - available = campaign.get_available(item_ids=[1, 2]) - created = campaign.create_multi(item_ids=[1], message="Скидка 10%", discount_percent=10) - confirmed = campaign.confirm_multi() - stats = campaign.get_stats() - tariff = campaign.get_tariff_info() - - assert available.items[0].is_available is True - assert created.status == "draft" - assert confirmed.status == "confirmed" - assert stats.delivered_count == 18 - assert tariff.daily_limit == 100 diff --git a/tests/test_stage5_promotion_read_contract.py b/tests/test_stage5_promotion_read_contract.py deleted file mode 100644 index 2507371..0000000 --- a/tests/test_stage5_promotion_read_contract.py +++ /dev/null @@ -1,163 +0,0 @@ -from __future__ import annotations - -import json - -import httpx -import pytest - -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import ResponseMappingError, Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts -from avito.promotion import ( - PromotionOrder, - PromotionOrderStatusResult, - TargetActionGetBidsResult, - TargetActionPricing, - TargetActionPromotion, -) - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_promotion_read_methods_return_documented_models() -> 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 == "/promotion/v1/items/services/orders/status": - assert payload == {"orderIds": ["ord-1"]} - return httpx.Response( - 200, - json={ - "orderId": "ord-1", - "status": "processed", - "totalPrice": 26166, - "items": [ - { - "itemId": 101, - "price": 9900, - "slug": "x2", - "status": "processed", - "errorReason": None, - } - ], - "errors": [{"itemId": 102, "errorCode": 1005, "errorText": "Недоступно"}], - }, - ) - if path == "/cpxpromo/1/getBids/101": - return httpx.Response( - 200, - json={ - "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, - "compare": 10, - } - ], - }, - }, - ) - assert path == "/cpxpromo/1/getPromotionsByItemIds" - assert payload == {"itemIDs": [101, 102]} - return httpx.Response( - 200, - json={ - "items": [ - { - "itemID": 102, - "actionTypeID": 7, - "autoPromotion": {"budgetPenny": 9000, "budgetType": "7d"}, - } - ] - }, - ) - - transport = make_transport(httpx.MockTransport(handler)) - - status = PromotionOrder(transport, resource_id="ord-1").get_order_status() - bids = TargetActionPricing(transport, resource_id=101).get_bids() - promotions = TargetActionPricing(transport, resource_id=101).get_promotions_by_item_ids( - item_ids=[101, 102] - ) - - assert isinstance(status, PromotionOrderStatusResult) - assert isinstance(bids, TargetActionGetBidsResult) - assert isinstance(promotions.items[0], TargetActionPromotion) - - assert status.to_dict() == { - "order_id": "ord-1", - "status": "processed", - "total_price": 26166, - "items": [ - { - "item_id": 101, - "price": 9900, - "slug": "x2", - "status": "processed", - "error_reason": None, - } - ], - "errors": [{"item_id": 102, "error_code": 1005, "error_text": "Недоступно"}], - } - assert bids.to_dict()["manual"]["bids"][0]["compare"] == 10 - assert promotions.to_dict() == { - "items": [ - { - "item_id": 102, - "action_type_id": 7, - "auto": {"budget_penny": 9000, "budget_type": "7d"}, - "manual": None, - } - ] - } - - -@pytest.mark.parametrize( - ("path", "body"), - [ - ("/promotion/v1/items/services/orders/status", {"items": []}), - ("/cpxpromo/1/getBids/102", {"items": []}), - ("/cpxpromo/1/getPromotionsByItemIds", {"items": [{"itemID": 102}]}), - ], -) -def test_documented_read_mappers_raise_on_invalid_shape(path: str, body: dict[str, object]) -> None: - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json=body) - - transport = make_transport(httpx.MockTransport(handler)) - - with pytest.raises(ResponseMappingError): - if path == "/promotion/v1/items/services/orders/status": - PromotionOrder(transport, resource_id="ord-2").get_order_status() - elif path == "/cpxpromo/1/getBids/102": - TargetActionPricing(transport, resource_id=102).get_bids() - else: - TargetActionPricing(transport, resource_id=102).get_promotions_by_item_ids( - item_ids=[102] - ) diff --git a/tests/test_stage6_autostrategy_list_signature.py b/tests/test_stage6_autostrategy_list_signature.py deleted file mode 100644 index fd485dd..0000000 --- a/tests/test_stage6_autostrategy_list_signature.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import annotations - -import json -from datetime import datetime - -import httpx - -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts -from avito.promotion import AutostrategyCampaign - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_autostrategy_list_accepts_keyword_fields_without_public_dto() -> None: - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/autostrategy/v1/campaigns" - assert json.loads(request.content.decode()) == { - "limit": 20, - "offset": 10, - "statusId": [1, 2], - "orderBy": [{"column": "startTime", "direction": "asc"}], - "filter": { - "byUpdateTime": { - "from": "2026-04-01T00:00:00", - "to": "2026-04-30T00:00:00", - } - }, - } - return httpx.Response( - 200, - json={ - "campaigns": [ - { - "campaignId": 77, - "campaignType": "AS", - "title": "Весенняя кампания", - "statusId": 1, - "version": 3, - } - ], - "totalCount": 1, - }, - ) - - transport = make_transport(httpx.MockTransport(handler)) - campaigns = AutostrategyCampaign(transport).list( - limit=20, - offset=10, - status_id=[1, 2], - order_by=[("startTime", "asc")], - updated_from=datetime(2026, 4, 1, 0, 0, 0), - updated_to=datetime(2026, 4, 30, 0, 0, 0), - ) - - assert campaigns.total_count == 1 - assert campaigns.items[0].campaign_id == 77 diff --git a/tests/test_stage6_error_model.py b/tests/test_stage6_error_model.py deleted file mode 100644 index 1dd11ab..0000000 --- a/tests/test_stage6_error_model.py +++ /dev/null @@ -1,137 +0,0 @@ -from __future__ import annotations - -from dataclasses import FrozenInstanceError - -import httpx -import pytest - -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import ( - AvitoError, - AuthorizationError, - ConflictError, - RateLimitError, - RequestContext, - ServerError, - Transport, - UnsupportedOperationError, - UpstreamApiError, - ValidationError, -) -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(client_id="client-id", client_secret="client-secret"), - retry_policy=RetryPolicy(max_attempts=1), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -@pytest.mark.parametrize( - ("status_code", "error_cls"), - [ - (400, ValidationError), - (422, ValidationError), - (401, AuthorizationError), - (403, AuthorizationError), - (409, ConflictError), - (429, RateLimitError), - (405, UnsupportedOperationError), - (418, UpstreamApiError), - (500, ServerError), - ], -) -def test_transport_maps_http_statuses_to_typed_sdk_errors( - status_code: int, - error_cls: type[Exception], -) -> None: - transport = make_transport( - httpx.MockTransport( - lambda request: httpx.Response( - status_code, - json={ - "message": "boom", - "code": "E_GENERIC", - "access_token": "secret-token", - }, - headers={"Authorization": "Bearer secret-token"}, - ) - ) - ) - - with pytest.raises(error_cls) as error: - transport.request_json( - "POST", - "/typed-errors", - context=RequestContext("promotion.target_action.update_manual_bid"), - ) - - assert str(error.value).find("operation=promotion.target_action.update_manual_bid") != -1 - assert error.value.status_code == status_code - assert error.value.error_code == "E_GENERIC" - assert error.value.metadata == {"method": "POST", "path": "/typed-errors"} - assert "secret-token" not in str(error.value.payload) - assert "secret-token" not in str(error.value.headers) - - -def test_transport_unknown_upstream_error_keeps_safe_context() -> None: - transport = make_transport( - httpx.MockTransport( - lambda request: httpx.Response( - 418, - json={ - "detail": "teapot", - "client_secret": "top-secret", - "nested": {"refresh_token": "hidden"}, - }, - ) - ) - ) - - with pytest.raises(UpstreamApiError) as error: - transport.request_json("GET", "/teapot", context=RequestContext("ads.list_items")) - - assert error.value.operation == "ads.list_items" - assert error.value.metadata == {"method": "GET", "path": "/teapot"} - assert error.value.payload == { - "detail": "teapot", - "client_secret": "***", - "nested": {"refresh_token": "***"}, - } - assert "top-secret" not in str(error.value) - - -def test_authorization_error_is_raised_for_auth_failures() -> None: - transport = make_transport( - httpx.MockTransport(lambda request: httpx.Response(401, json={"message": "unauthorized"})) - ) - - with pytest.raises(AuthorizationError, match="unauthorized") as error: - transport.request_json("GET", "/secure", context=RequestContext("accounts.get_self")) - - assert error.value.operation == "accounts.get_self" - - -def test_avito_error_is_frozen_dataclass() -> None: - error = AvitoError( - "boom", - payload={"access_token": "secret-token"}, - headers={"Authorization": "Bearer secret-token"}, - ) - - with pytest.raises(FrozenInstanceError): - error.message = "updated" - - assert error.payload == {"access_token": "***"} - assert error.headers == {"Authorization": "***"} diff --git a/tests/test_stage6_promotion.py b/tests/test_stage6_promotion.py deleted file mode 100644 index 4d352ab..0000000 --- a/tests/test_stage6_promotion.py +++ /dev/null @@ -1,500 +0,0 @@ -from __future__ import annotations - -import json - -import httpx - -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts -from avito.promotion import ( - AutostrategyCampaign, - BbipForecastRequestItem, - BbipOrderItem, - BbipPromotion, - CpaAuction, - CreateItemBid, - PromotionOrder, - TargetActionPricing, - TrxPromotion, - TrxPromotionApplyItem, -) -from avito.promotion.models import ( - CampaignListFilter, - CampaignOrderBy, - CampaignUpdateTimeFilter, - CreateAutostrategyBudgetRequest, - CreateAutostrategyCampaignRequest, - ListAutostrategyCampaignsRequest, - UpdateAutostrategyCampaignRequest, -) - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_promotion_dictionary_and_orders_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 == "/promotion/v1/items/services/dict": - return httpx.Response(200, json={"items": [{"code": "x2", "title": "X2"}]}) - if path == "/promotion/v1/items/services/get": - assert payload == {"itemIds": [101]} - return httpx.Response( - 200, - json={ - "items": [ - { - "itemId": 101, - "serviceCode": "x2", - "serviceName": "X2", - "price": 9900, - "status": "available", - } - ] - }, - ) - if path == "/promotion/v1/items/services/orders/get": - assert payload == {"itemIds": [101]} - return httpx.Response( - 200, - json={ - "items": [ - { - "orderId": "ord-1", - "itemId": 101, - "serviceCode": "x2", - "status": "created", - } - ] - }, - ) - assert path == "/promotion/v1/items/services/orders/status" - assert payload == {"orderIds": ["ord-1"]} - return httpx.Response( - 200, - json={"orderId": "ord-1", "status": "processed", "items": [], "errors": []}, - ) - - promotion = PromotionOrder(make_transport(httpx.MockTransport(handler)), resource_id="ord-1") - - dictionary = promotion.get_service_dictionary() - services = promotion.list_services(item_ids=[101]) - orders = promotion.list_orders(item_ids=[101]) - statuses = promotion.get_order_status() - - assert dictionary.items[0].code == "x2" - assert services.items[0].price == 9900 - assert orders.items[0].order_id == "ord-1" - assert statuses.status == "processed" - - -def test_bbip_and_trxpromo_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 == "/promotion/v1/items/services/bbip/forecasts/get": - assert payload == { - "items": [{"itemId": 101, "duration": 7, "price": 1000, "oldPrice": 1200}] - } - return httpx.Response( - 200, - json={ - "items": [ - { - "itemId": 101, - "min": 10, - "max": 25, - "totalPrice": 7000, - "totalOldPrice": 8400, - } - ] - }, - ) - if path == "/promotion/v1/items/services/bbip/orders/create": - assert request.method == "PUT" - assert payload == { - "items": [{"itemId": 101, "duration": 7, "price": 1000, "oldPrice": 1200}] - } - return httpx.Response( - 200, json={"items": [{"itemId": 101, "success": True, "status": "created"}]} - ) - if path == "/promotion/v1/items/services/bbip/suggests/get": - assert payload == {"itemIds": [101]} - return httpx.Response( - 200, - json={ - "items": [ - { - "itemId": 101, - "duration": {"from": 1, "to": 7, "recommended": 5}, - "budgets": [{"price": 1000, "oldPrice": 1200, "isRecommended": True}], - } - ] - }, - ) - if path == "/trx-promo/1/apply": - assert payload == { - "items": [{"itemID": 101, "commission": 1500, "dateFrom": "2026-04-18"}] - } - return httpx.Response( - 200, json={"success": {"items": [{"itemID": 101, "success": True}]}} - ) - if path == "/trx-promo/1/cancel": - assert payload == {"items": [{"itemID": 101}]} - return httpx.Response( - 200, json={"success": {"items": [{"itemID": 101, "success": True}]}} - ) - assert path == "/trx-promo/1/commissions" - assert request.url.params["itemIDs"] == "101" - return httpx.Response( - 200, - json={ - "success": { - "items": [ - { - "itemID": 101, - "commission": 1500, - "isActive": True, - "validCommissionRange": { - "valueMin": 100, - "valueMax": 2000, - "step": 100, - }, - } - ] - } - }, - ) - - transport = make_transport(httpx.MockTransport(handler)) - bbip = BbipPromotion(transport, resource_id=101) - trx = TrxPromotion(transport, resource_id=101) - - forecasts = bbip.get_forecasts( - items=[BbipForecastRequestItem(item_id=101, duration=7, price=1000, old_price=1200)] - ) - order_result = bbip.create_order( - items=[BbipOrderItem(item_id=101, duration=7, price=1000, old_price=1200)] - ) - suggests = bbip.get_suggests() - applied = trx.apply( - items=[TrxPromotionApplyItem(item_id=101, commission=1500, date_from="2026-04-18")] - ) - cancelled = trx.delete() - commissions = trx.get_commissions() - - assert forecasts.items[0].max_views == 25 - assert order_result.status == "created" - assert order_result.applied is True - assert suggests.items[0].duration is not None and suggests.items[0].duration.recommended == 5 - assert applied.applied is True - assert cancelled.applied is True - assert commissions.items[0].valid_commission_range is not None - assert commissions.items[0].valid_commission_range.value_max == 2000 - - -def test_cpa_auction_and_target_action_pricing_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 == "/auction/1/bids" and request.method == "GET": - assert request.url.params["fromItemID"] == "100" - assert request.url.params["batchSize"] == "50" - return httpx.Response( - 200, - json={ - "items": [ - { - "itemID": 101, - "pricePenny": 1300, - "expirationTime": "2026-04-18T10:00:00+03:00", - "availablePrices": [{"pricePenny": 1200, "goodness": 1}], - } - ] - }, - ) - if path == "/auction/1/bids": - assert payload == {"items": [{"itemID": 101, "pricePenny": 1500}]} - return httpx.Response(200, json={"items": [{"itemID": 101, "success": True}]}) - if path == "/cpxpromo/1/getBids/101": - return httpx.Response( - 200, - json={ - "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, - "compare": 10, - } - ], - }, - }, - ) - if path == "/cpxpromo/1/getPromotionsByItemIds": - assert payload == {"itemIDs": [101, 102]} - return httpx.Response( - 200, - json={ - "items": [ - { - "itemID": 102, - "actionTypeID": 7, - "autoPromotion": {"budgetPenny": 9000, "budgetType": "7d"}, - } - ] - }, - ) - if path == "/cpxpromo/1/remove": - assert payload == {"itemID": 101} - return httpx.Response( - 200, json={"items": [{"itemID": 101, "success": True, "status": "removed"}]} - ) - if path == "/cpxpromo/1/setAuto": - assert payload == { - "itemID": 101, - "actionTypeID": 5, - "budgetPenny": 8000, - "budgetType": "7d", - } - return httpx.Response( - 200, json={"items": [{"itemID": 101, "success": True, "status": "auto"}]} - ) - assert path == "/cpxpromo/1/setManual" - assert payload == {"itemID": 101, "actionTypeID": 5, "bidPenny": 1500, "limitPenny": 15000} - return httpx.Response( - 200, json={"items": [{"itemID": 101, "success": True, "status": "manual"}]} - ) - - transport = make_transport(httpx.MockTransport(handler)) - auction = CpaAuction(transport) - pricing = TargetActionPricing(transport, resource_id=101) - - bids = auction.get_user_bids(from_item_id=100, batch_size=50) - saved = auction.create_item_bids(items=[CreateItemBid(item_id=101, price_penny=1500)]) - details = pricing.get_bids() - promotions = pricing.get_promotions_by_item_ids(item_ids=[101, 102]) - removed = pricing.delete() - auto = pricing.update_auto(action_type_id=5, budget_penny=8000, budget_type="7d") - manual = pricing.update_manual(action_type_id=5, bid_penny=1500, limit_penny=15000) - - assert bids.items[0].available_prices[0].price_penny == 1200 - assert saved.applied is True - assert details.manual is not None and details.manual.bids[0].compare == 10 - assert promotions.items[0].auto is not None - assert removed.status == "removed" - assert auto.status == "auto" - assert manual.status == "manual" - - -def test_autostrategy_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 == "/autostrategy/v1/budget": - assert payload == { - "campaignType": "AS", - "startTime": "2026-04-20T00:00:00Z", - "finishTime": "2026-04-27T00:00:00Z", - "items": [101, 102], - } - return httpx.Response( - 200, - json={ - "calcId": 501, - "budget": { - "recommended": { - "total": 10100, - "real": 10000, - "bonus": 100, - "viewsFrom": 30, - "viewsTo": 50, - }, - "minimal": {"total": 5100, "real": 5000, "bonus": 100}, - "priceRanges": [ - { - "priceFrom": 10000, - "priceTo": 20000, - "percent": 90, - "viewsFrom": 20, - "viewsTo": 40, - } - ], - }, - }, - ) - if path == "/autostrategy/v1/campaign/create": - assert payload == { - "campaignType": "AS", - "title": "Весенняя кампания", - "budget": 10000, - "calcId": 501, - "items": [101, 102], - "startTime": "2026-04-20T00:00:00Z", - "finishTime": "2026-04-27T00:00:00Z", - } - return httpx.Response( - 200, - json={"campaign": {"campaignId": 77, "campaignType": "AS", "version": 3}}, - ) - if path == "/autostrategy/v1/campaign/edit": - assert payload == {"campaignId": 77, "version": 3, "title": "Обновленная кампания"} - return httpx.Response( - 200, - json={"campaign": {"campaignId": 77, "campaignType": "AS", "version": 4}}, - ) - if path == "/autostrategy/v1/campaign/info": - assert payload == {"campaignId": 77} - return httpx.Response( - 200, - json={ - "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}], - }, - ) - if path == "/autostrategy/v1/campaign/stop": - assert payload == {"campaignId": 77, "version": 4} - return httpx.Response( - 200, - json={"campaign": {"campaignId": 77, "campaignType": "AS", "version": 5}}, - ) - if path == "/autostrategy/v1/campaigns": - assert payload == { - "limit": 20, - "offset": 10, - "statusId": [1, 2], - "orderBy": [{"column": "startTime", "direction": "asc"}], - "filter": { - "byUpdateTime": { - "from": "2026-04-01T00:00:00Z", - "to": "2026-04-30T00:00:00Z", - } - }, - } - return httpx.Response( - 200, - json={ - "campaigns": [ - { - "campaignId": 77, - "campaignType": "AS", - "statusId": 1, - "budget": 10000, - } - ], - "totalCount": 1, - }, - ) - assert path == "/autostrategy/v1/stat" - assert payload == {"campaignId": 77} - return httpx.Response( - 200, - json={ - "stat": [ - { - "date": "2026-04-18", - "calls": 30, - "views": 500, - "callsForecast": {"from": 25, "to": 35}, - "viewsForecast": {"from": 450, "to": 550}, - } - ], - "totals": {"calls": 30, "views": 500}, - }, - ) - - campaign = AutostrategyCampaign(make_transport(httpx.MockTransport(handler)), resource_id=77) - - budget = campaign.create_budget( - request=CreateAutostrategyBudgetRequest( - campaign_type="AS", - start_time="2026-04-20T00:00:00Z", - finish_time="2026-04-27T00:00:00Z", - items=[101, 102], - ) - ) - created = campaign.create( - request=CreateAutostrategyCampaignRequest( - campaign_type="AS", - title="Весенняя кампания", - budget=10000, - calc_id=501, - items=[101, 102], - start_time="2026-04-20T00:00:00Z", - finish_time="2026-04-27T00:00:00Z", - ) - ) - updated = campaign.update( - request=UpdateAutostrategyCampaignRequest( - campaign_id=77, - version=3, - title="Обновленная кампания", - ) - ) - info = campaign.get() - stopped = campaign.delete(version=4) - campaigns = campaign.list( - request=ListAutostrategyCampaignsRequest( - limit=20, - offset=10, - status_id=[1, 2], - order_by=[CampaignOrderBy(column="startTime", direction="asc")], - filter=CampaignListFilter( - by_update_time=CampaignUpdateTimeFilter( - from_time="2026-04-01T00:00:00Z", - to_time="2026-04-30T00:00:00Z", - ) - ), - ) - ) - stat = campaign.get_stat() - - assert budget.calc_id == 501 - assert budget.recommended is not None and budget.recommended.total == 10100 - assert created.campaign is not None and created.campaign.version == 3 - assert updated.campaign is not None and updated.campaign.version == 4 - assert info.campaign is not None and info.campaign.balance == 9000 - assert info.items[0].item_id == 101 - assert stopped.campaign is not None and stopped.campaign.version == 5 - assert campaigns.items[0].campaign_id == 77 - assert campaigns.total_count == 1 - assert stat.totals is not None and stat.totals.views == 500 - assert stat.items[0].calls == 30 diff --git a/tests/test_stage7_orders.py b/tests/test_stage7_orders.py deleted file mode 100644 index 84f18bf..0000000 --- a/tests/test_stage7_orders.py +++ /dev/null @@ -1,751 +0,0 @@ -from __future__ import annotations - -import json - -import httpx - -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts -from avito.orders import DeliveryOrder, DeliveryTask, Order, OrderLabel, SandboxDelivery, Stock -from avito.orders.models import ( - AddSortingCentersRequest, - AddTariffV2Request, - CancelParcelRequest, - CancelSandboxParcelRequest, - ChangeParcelRequest, - CustomAreaScheduleEntry, - CustomAreaScheduleRequest, - DeliveryAddress, - DeliveryAnnouncementRequest, - DeliveryDateInterval, - DeliveryDirection, - DeliveryDirectionZone, - DeliveryParcelIdsRequest, - DeliveryParcelRequest, - DeliveryParcelResultRequest, - DeliveryRestriction, - DeliveryTariffItem, - DeliveryTariffValue, - DeliveryTariffZone, - DeliveryTerms, - DeliveryTermsZone, - DeliveryTrackingRequest, - GetChangeParcelInfoRequest, - GetRegisteredParcelIdRequest, - GetSandboxParcelInfoRequest, - OrderAcceptReturnRequest, - OrderApplyTransitionRequest, - OrderCncDetailsRequest, - OrderConfirmationCodeRequest, - OrderCourierRangeRequest, - OrderDeliveryProperties, - OrderLabelsRequest, - OrderMarkingsRequest, - OrderTrackingNumberRequest, - ProhibitOrderAcceptanceRequest, - RealAddress, - SandboxAnnouncementDelivery, - SandboxAnnouncementPackage, - SandboxAnnouncementParticipant, - SandboxArea, - SandboxAreasRequest, - SandboxCancelAnnouncementOptions, - SandboxCancelAnnouncementRequest, - SandboxConfirmationCodeRequest, - SandboxCreateAnnouncementOptions, - SandboxCreateAnnouncementRequest, - SandboxDeliveryPoint, - SandboxGetAnnouncementEventRequest, - SetOrderPropertiesRequest, - SetOrderRealAddressRequest, - SortingCenterUpload, - StockInfoRequest, - StockUpdateEntry, - StockUpdateRequest, - TaggedSortingCenter, - TaggedSortingCentersRequest, - UpdateTermsRequest, - WeeklySchedule, -) - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_order_management_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 == "/order-management/1/orders": - return httpx.Response( - 200, - json={ - "orders": [{"id": "ord-1", "status": "new", "buyerInfo": {"fullName": "Иван"}}], - "total": 1, - }, - ) - if path == "/order-management/1/markings": - assert payload == {"orderId": "ord-1", "codes": ["abc"]} - return httpx.Response( - 200, json={"result": {"success": True, "orderId": "ord-1", "status": "marked"}} - ) - if path == "/order-management/1/order/applyTransition": - assert payload == {"orderId": "ord-1", "transition": "confirm"} - return httpx.Response( - 200, json={"result": {"success": True, "orderId": "ord-1", "status": "confirmed"}} - ) - if path == "/order-management/1/order/checkConfirmationCode": - assert payload == {"orderId": "ord-1", "code": "1234"} - return httpx.Response( - 200, json={"result": {"success": True, "orderId": "ord-1", "status": "code-valid"}} - ) - if path == "/order-management/1/order/cncSetDetails": - assert payload == {"orderId": "ord-1", "pickupPointId": "pvz-1"} - return httpx.Response( - 200, json={"result": {"success": True, "orderId": "ord-1", "status": "pickup-set"}} - ) - if path == "/order-management/1/order/getCourierDeliveryRange": - return httpx.Response( - 200, - json={ - "result": { - "address": "Москва", - "timeIntervals": [ - { - "id": "int-1", - "date": "2026-04-18", - "startAt": "10:00", - "endAt": "12:00", - } - ], - } - }, - ) - if path == "/order-management/1/order/setCourierDeliveryRange": - assert payload == {"orderId": "ord-1", "intervalId": "int-1"} - return httpx.Response(200, json={"result": {"success": True, "status": "range-set"}}) - if path == "/order-management/1/order/setTrackingNumber": - assert payload == {"orderId": "ord-1", "trackingNumber": "TRK-1"} - return httpx.Response(200, json={"result": {"success": True, "status": "tracking-set"}}) - assert path == "/order-management/1/order/acceptReturnOrder" - assert payload == {"orderId": "ord-1", "postalOfficeId": "ops-1"} - return httpx.Response(200, json={"result": {"success": True, "status": "return-accepted"}}) - - order = Order(make_transport(httpx.MockTransport(handler)), resource_id="ord-1") - - orders = order.list() - marked = order.update_markings(request=OrderMarkingsRequest(order_id="ord-1", codes=["abc"])) - applied = order.apply( - request=OrderApplyTransitionRequest(order_id="ord-1", transition="confirm") - ) - code_checked = order.check_confirmation_code( - request=OrderConfirmationCodeRequest(order_id="ord-1", code="1234") - ) - cnc = order.set_cnc_details( - request=OrderCncDetailsRequest(order_id="ord-1", pickup_point_id="pvz-1") - ) - courier_ranges = order.get_courier_delivery_range() - courier_set = order.set_courier_delivery_range( - request=OrderCourierRangeRequest(order_id="ord-1", interval_id="int-1") - ) - tracking = order.update_tracking_number( - request=OrderTrackingNumberRequest(order_id="ord-1", tracking_number="TRK-1") - ) - returned = order.accept_return_order( - request=OrderAcceptReturnRequest(order_id="ord-1", postal_office_id="ops-1") - ) - - assert orders.items[0].buyer_name == "Иван" - assert marked.status == "marked" - assert applied.status == "confirmed" - assert code_checked.status == "code-valid" - assert cnc.status == "pickup-set" - assert courier_ranges.items[0].interval_id == "int-1" - assert courier_set.status == "range-set" - assert tracking.status == "tracking-set" - assert returned.status == "return-accepted" - - -def test_labels_binary_download_flow() -> None: - pdf_bytes = b"%PDF-1.4 fake" - - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/order-management/1/orders/labels": - assert json.loads(request.content.decode()) == {"orderIds": ["ord-1"]} - return httpx.Response(200, json={"result": {"taskId": 42, "status": "created"}}) - assert request.url.path == "/order-management/1/orders/labels/42/download" - return httpx.Response( - 200, - content=pdf_bytes, - headers={ - "content-type": "application/pdf", - "content-disposition": 'attachment; filename="label-42.pdf"', - }, - ) - - label = OrderLabel(make_transport(httpx.MockTransport(handler)), resource_id="42") - - task = label.create(request=OrderLabelsRequest(order_ids=["ord-1"])) - pdf = label.download() - - assert task.task_id == "42" - assert pdf.filename == "label-42.pdf" - assert pdf.binary.content == pdf_bytes - - -def test_delivery_production_and_sandbox_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 == "/createAnnouncement": - assert payload == {"orderId": "ord-1"} - return httpx.Response( - 200, json={"data": {"taskId": 11, "status": "announcement-created"}} - ) - if path == "/createParcel": - assert payload == {"orderId": "ord-1", "parcelId": "par-1"} - return httpx.Response( - 200, json={"data": {"parcelId": "par-1", "status": "parcel-created"}} - ) - if path == "/cancelAnnouncement": - assert payload == {"orderId": "ord-1"} - return httpx.Response(200, json={"data": {"status": "announcement-cancelled"}}) - if path == "/delivery/order/changeParcelResult": - assert payload == {"parcelId": "par-1", "result": "ok"} - return httpx.Response(200, json={"data": {"status": "callback-accepted"}}) - if path == "/sandbox/changeParcels": - assert payload == {"parcelIds": ["par-1"]} - return httpx.Response(200, json={"data": {"status": "parcels-updated"}}) - if path == "/delivery-sandbox/announcements/create": - assert payload == {"orderId": "sand-1"} - return httpx.Response( - 200, json={"data": {"taskId": 51, "status": "sandbox-announcement-created"}} - ) - if path == "/delivery-sandbox/announcements/track": - assert payload == {"orderId": "sand-1"} - return httpx.Response(200, json={"data": {"status": "tracked"}}) - if path == "/delivery-sandbox/areas/custom-schedule": - assert payload == [ - { - "providerAreaNumber": ["area-1"], - "services": ["delivery"], - "customSchedule": [{"date": "2026-04-20", "intervals": ["10:00:00/18:00:00"]}], - } - ] - return httpx.Response(200, json={"data": {"status": "schedule-updated"}}) - if path == "/delivery-sandbox/cancelParcel": - assert payload == {"parcelID": "spar-1", "actor": "receiver"} - return httpx.Response(200, json={"data": {"status": "parcel-cancelled"}}) - if path == "/delivery-sandbox/order/checkConfirmationCode": - assert payload == {"parcelID": "spar-1", "confirmCode": "1234"} - return httpx.Response(200, json={"data": {"status": "success"}}) - if path == "/delivery-sandbox/order/properties": - assert payload == { - "orderId": "sand-1", - "properties": { - "delivery": {"cost": 19900}, - "dimensions": [10, 20, 30], - "weight": 500, - }, - } - return httpx.Response(200, json={"data": {"status": "properties-set"}}) - if path == "/delivery-sandbox/order/realAddress": - assert payload == { - "orderId": "sand-1", - "address": {"addressType": "SENDER_SEND", "terminalNumber": "term-1"}, - } - return httpx.Response(200, json={"data": {"status": "real-address-set"}}) - if path == "/delivery-sandbox/order/tracking": - assert payload == { - "orderId": "sand-1", - "avitoStatus": "IN_TRANSIT", - "avitoEventType": "RECEIVED_AT_TRANSIT_TERMINAL", - "providerEventCode": "evt-1", - "date": "2026-04-20T10:00:00Z", - "location": "Москва", - } - return httpx.Response(200, json={"data": {"status": "tracking-saved"}}) - if path == "/delivery-sandbox/prohibitOrderAcceptance": - assert payload == {"orderId": "sand-1"} - return httpx.Response(200, json={"data": {"status": "acceptance-prohibited"}}) - if path == "/delivery-sandbox/sorting-center": - return httpx.Response( - 200, - json={ - "data": { - "sortingCenters": [{"id": "sc-1", "name": "Центр 1", "city": "Москва"}] - } - }, - ) - if path == "/delivery-sandbox/tariffs/sorting-center": - assert payload == [ - { - "deliveryProviderId": "sc-1", - "name": "Центр 1", - "address": { - "country": "Россия", - "region": "Москва", - "locality": "Москва", - "fias": "fias-1", - "zipCode": "101000", - "lat": 55.75, - "lng": 37.61, - }, - "phones": ["79990000000"], - "itinerary": "Вход справа", - "photos": [], - "schedule": { - "mon": ["09:00:00/18:00:00"], - "tue": ["09:00:00/18:00:00"], - "wed": ["09:00:00/18:00:00"], - "thu": ["09:00:00/18:00:00"], - "fri": ["09:00:00/18:00:00"], - "sat": [], - "sun": [], - }, - "restriction": { - "maxWeight": 10000, - "maxDimensions": [100, 50, 50], - "maxDeclaredCost": 100000, - }, - "directionTag": "moscow", - } - ] - return httpx.Response( - 200, json={"data": {"taskId": 62, "status": "sorting-centers-added"}} - ) - if path == "/delivery-sandbox/tariffs/tf-1/areas": - assert payload == {"areas": [{"city": "Москва"}]} - return httpx.Response(200, json={"data": {"taskId": 61, "status": "areas-added"}}) - if path == "/delivery-sandbox/tariffs/tf-1/tagged-sorting-centers": - assert payload == [{"deliveryProviderId": "sc-1", "directionTag": "moscow"}] - return httpx.Response(200, json={"data": {"status": "tags-added"}}) - if path == "/delivery-sandbox/tariffs/tf-1/terms": - assert payload == [ - { - "deliveryProviderZoneId": "zone-1", - "minTerm": 1, - "maxTerm": 2, - "name": "zone", - } - ] - return httpx.Response(200, json={"data": {"status": "terms-updated"}}) - if path == "/delivery-sandbox/tariffsV2": - assert payload == { - "name": "Tariff", - "deliveryProviderTariffId": "tariff-1", - "directions": [ - { - "providerDirectionId": "dir-1", - "tagFrom": "moscow", - "tagTo": "spb", - "zones": [{"tariffZoneId": "tz-1", "termsZoneId": "term-1", "type": "0"}], - } - ], - "tariffZones": [ - { - "name": "Zone", - "deliveryProviderZoneId": "tz-1", - "items": [ - { - "calculationMechanic": "GAP_TO_COST", - "chargeableParameter": "WEIGHT", - "serviceName": "DELIVERY", - "values": [{"cost": 10000, "maxWeight": 3000}], - } - ], - } - ], - "termsZones": [ - { - "deliveryProviderZoneId": "term-1", - "minTerm": 1, - "maxTerm": 2, - "name": "term", - } - ], - } - return httpx.Response(200, json={"data": {"taskId": 63, "status": "tariff-added"}}) - if path == "/delivery-sandbox/v1/cancelAnnouncement": - assert payload == { - "announcementID": "ann-1", - "date": "2026-04-20T10:00:00Z", - "options": {"urlToCancelAnnouncement": "https://example.com/cancel"}, - } - return httpx.Response(200, json={"data": {"status": "announcement-cancel-requested"}}) - if path == "/delivery-sandbox/v1/cancelParcel": - assert payload == {"parcelID": "spar-1"} - return httpx.Response(200, json={"data": {"status": "sandbox-parcel-cancelled"}}) - if path == "/delivery-sandbox/v1/changeParcel": - assert payload == {"type": "changeReceiver", "parcelID": "spar-1"} - return httpx.Response(200, json={"data": {"status": "change-created"}}) - if path == "/delivery-sandbox/v1/createAnnouncement": - assert payload == { - "announcementID": "ann-1", - "barcode": "barcode-1", - "sender": { - "type": "3PL", - "phones": ["79990000000"], - "email": "sender@example.com", - "name": "Sender", - "delivery": { - "type": "TERMINAL", - "terminal": {"provider": "avito", "id": "term-1"}, - }, - }, - "receiver": { - "type": "ABD", - "phones": ["79990000001"], - "email": "receiver@example.com", - "name": "Receiver", - "delivery": { - "type": "TERMINAL", - "terminal": {"provider": "avito", "id": "term-2"}, - }, - }, - "announcementType": "DELIVERY", - "date": "2026-04-20T10:00:00Z", - "packages": [{"id": "pkg-1", "parcelIDs": ["spar-1"]}], - "options": {"urlToSendAnnouncement": "https://example.com/announce"}, - } - return httpx.Response(200, json={"data": {"status": "announcement-created-v1"}}) - if path == "/delivery-sandbox/v1/getAnnouncementEvent": - assert payload == {"announcementID": "ann-1"} - return httpx.Response(200, json={"data": {"status": "event-ready"}}) - if path == "/delivery-sandbox/v1/getChangeParcelInfo": - assert payload == {"applicationID": "app-1"} - return httpx.Response(200, json={"data": {"status": "change-info-ready"}}) - if path == "/delivery-sandbox/v1/getParcelInfo": - assert payload == {"parcelID": "spar-1"} - return httpx.Response(200, json={"data": {"status": "parcel-info-ready"}}) - if path == "/delivery-sandbox/v1/getRegisteredParcelID": - assert payload == {"orderID": "sand-1"} - return httpx.Response(200, json={"data": {"parcelId": "reg-1", "status": "registered"}}) - if path == "/delivery-sandbox/v2/createParcel": - assert payload == {"orderId": "sand-1", "parcelId": "spar-1"} - return httpx.Response( - 200, json={"data": {"parcelId": "spar-1", "status": "sandbox-parcel-created"}} - ) - assert path == "/delivery-sandbox/tasks/51" - return httpx.Response(200, json={"data": {"taskId": 51, "status": "done"}}) - - transport = make_transport(httpx.MockTransport(handler)) - delivery = DeliveryOrder(transport, resource_id="ord-1") - sandbox = SandboxDelivery(transport, resource_id="sand-1") - task = DeliveryTask(transport, resource_id="51") - - announcement = delivery.create_announcement( - request=DeliveryAnnouncementRequest(order_id="ord-1") - ) - parcel = delivery.create(request=DeliveryParcelRequest(order_id="ord-1", parcel_id="par-1")) - cancelled = delivery.delete(request=DeliveryAnnouncementRequest(order_id="ord-1")) - callback = delivery.create_change_parcel_result( - request=DeliveryParcelResultRequest(parcel_id="par-1", result="ok") - ) - changed = delivery.update_change_parcels(request=DeliveryParcelIdsRequest(parcel_ids=["par-1"])) - sandbox_announcement = sandbox.create_announcement( - request=DeliveryAnnouncementRequest(order_id="sand-1") - ) - tracked = sandbox.track_announcement(request=DeliveryAnnouncementRequest(order_id="sand-1")) - schedule = sandbox.update_custom_area_schedule( - request=CustomAreaScheduleRequest( - items=[ - CustomAreaScheduleEntry( - provider_area_numbers=["area-1"], - services=["delivery"], - custom_schedule=[ - DeliveryDateInterval( - date="2026-04-20", - intervals=["10:00:00/18:00:00"], - ) - ], - ) - ] - ) - ) - cancelled_parcel = sandbox.cancel_parcel( - request=CancelParcelRequest(parcel_id="spar-1", actor="receiver") - ) - sandbox_code_checked = sandbox.check_confirmation_code( - request=SandboxConfirmationCodeRequest(parcel_id="spar-1", confirm_code="1234") - ) - props = sandbox.set_order_properties( - request=SetOrderPropertiesRequest( - order_id="sand-1", - properties=OrderDeliveryProperties( - delivery=DeliveryTerms(cost=19900), - dimensions=[10, 20, 30], - weight=500, - ), - ) - ) - real_address = sandbox.set_order_real_address( - request=SetOrderRealAddressRequest( - order_id="sand-1", - address=RealAddress(address_type="SENDER_SEND", terminal_number="term-1"), - ) - ) - tracking = sandbox.tracking( - request=DeliveryTrackingRequest( - order_id="sand-1", - avito_status="IN_TRANSIT", - avito_event_type="RECEIVED_AT_TRANSIT_TERMINAL", - provider_event_code="evt-1", - date="2026-04-20T10:00:00Z", - location="Москва", - ) - ) - prohibited = sandbox.prohibit_order_acceptance( - request=ProhibitOrderAcceptanceRequest(order_id="sand-1") - ) - centers = sandbox.list_sorting_center() - address = DeliveryAddress( - country="Россия", - region="Москва", - locality="Москва", - fias="fias-1", - zip_code="101000", - lat=55.75, - lng=37.61, - ) - schedule_model = WeeklySchedule( - mon=["09:00:00/18:00:00"], - tue=["09:00:00/18:00:00"], - wed=["09:00:00/18:00:00"], - thu=["09:00:00/18:00:00"], - fri=["09:00:00/18:00:00"], - sat=[], - sun=[], - ) - restriction = DeliveryRestriction( - max_weight=10000, - max_dimensions=[100, 50, 50], - max_declared_cost=100000, - ) - sorting_centers_added = sandbox.add_sorting_center( - request=AddSortingCentersRequest( - items=[ - SortingCenterUpload( - delivery_provider_id="sc-1", - name="Центр 1", - address=address, - phones=["79990000000"], - itinerary="Вход справа", - photos=[], - schedule=schedule_model, - restriction=restriction, - direction_tag="moscow", - ) - ] - ) - ) - added_areas = sandbox.add_areas( - tariff_id="tf-1", - request=SandboxAreasRequest(areas=[SandboxArea(city="Москва")]), - ) - tagged = sandbox.add_tags_to_sorting_center( - tariff_id="tf-1", - request=TaggedSortingCentersRequest( - items=[TaggedSortingCenter(delivery_provider_id="sc-1", direction_tag="moscow")] - ), - ) - updated_terms = sandbox.update_terms( - tariff_id="tf-1", - request=UpdateTermsRequest( - items=[ - DeliveryTermsZone( - delivery_provider_zone_id="zone-1", min_term=1, max_term=2, name="zone" - ) - ] - ), - ) - tariff = sandbox.add_tariff( - request=AddTariffV2Request( - name="Tariff", - delivery_provider_tariff_id="tariff-1", - directions=[ - DeliveryDirection( - provider_direction_id="dir-1", - tag_from="moscow", - tag_to="spb", - zones=[ - DeliveryDirectionZone( - tariff_zone_id="tz-1", terms_zone_id="term-1", type="0" - ) - ], - ) - ], - tariff_zones=[ - DeliveryTariffZone( - name="Zone", - delivery_provider_zone_id="tz-1", - items=[ - DeliveryTariffItem( - calculation_mechanic="GAP_TO_COST", - chargeable_parameter="WEIGHT", - service_name="DELIVERY", - values=[DeliveryTariffValue(cost=10000, max_weight=3000)], - ) - ], - ) - ], - terms_zones=[ - DeliveryTermsZone( - delivery_provider_zone_id="term-1", min_term=1, max_term=2, name="term" - ) - ], - ) - ) - cancelled_announcement_v1 = sandbox.cancel_sandbox_announcement( - request=SandboxCancelAnnouncementRequest( - announcement_id="ann-1", - date="2026-04-20T10:00:00Z", - options=SandboxCancelAnnouncementOptions( - url_to_cancel_announcement="https://example.com/cancel" - ), - ) - ) - cancelled_parcel_v1 = sandbox.cancel_sandbox_parcel( - request=CancelSandboxParcelRequest(parcel_id="spar-1") - ) - changed_parcel_v1 = sandbox.change_sandbox_parcel( - request=ChangeParcelRequest(type="changeReceiver", parcel_id="spar-1") - ) - created_announcement_v1 = sandbox.create_sandbox_announcement( - request=SandboxCreateAnnouncementRequest( - announcement_id="ann-1", - barcode="barcode-1", - sender=SandboxAnnouncementParticipant( - type="3PL", - phones=["79990000000"], - email="sender@example.com", - name="Sender", - delivery=SandboxAnnouncementDelivery( - type="TERMINAL", - terminal=SandboxDeliveryPoint(provider="avito", point_id="term-1"), - ), - ), - receiver=SandboxAnnouncementParticipant( - type="ABD", - phones=["79990000001"], - email="receiver@example.com", - name="Receiver", - delivery=SandboxAnnouncementDelivery( - type="TERMINAL", - terminal=SandboxDeliveryPoint(provider="avito", point_id="term-2"), - ), - ), - announcement_type="DELIVERY", - date="2026-04-20T10:00:00Z", - packages=[SandboxAnnouncementPackage(package_id="pkg-1", parcel_ids=["spar-1"])], - options=SandboxCreateAnnouncementOptions( - url_to_send_announcement="https://example.com/announce" - ), - ) - ) - event_v1 = sandbox.get_sandbox_announcement_event( - request=SandboxGetAnnouncementEventRequest(announcement_id="ann-1") - ) - change_info_v1 = sandbox.get_sandbox_change_parcel_info( - request=GetChangeParcelInfoRequest(application_id="app-1") - ) - parcel_info_v1 = sandbox.get_sandbox_parcel_info( - request=GetSandboxParcelInfoRequest(parcel_id="spar-1") - ) - registered_parcel_id_v1 = sandbox.get_sandbox_registered_parcel_id( - request=GetRegisteredParcelIdRequest(order_id="sand-1") - ) - sandbox_parcel = sandbox.create_parcel( - request=DeliveryParcelRequest(order_id="sand-1", parcel_id="spar-1") - ) - task_info = task.get() - - assert announcement.task_id == "11" - assert parcel.parcel_id == "par-1" - assert cancelled.status == "announcement-cancelled" - assert callback.status == "callback-accepted" - assert changed.status == "parcels-updated" - assert sandbox_announcement.task_id == "51" - assert tracked.status == "tracked" - assert schedule.status == "schedule-updated" - assert cancelled_parcel.status == "parcel-cancelled" - assert sandbox_code_checked.status == "success" - assert props.status == "properties-set" - assert real_address.status == "real-address-set" - assert tracking.status == "tracking-saved" - assert prohibited.status == "acceptance-prohibited" - assert centers.items[0].city == "Москва" - assert sorting_centers_added.task_id == "62" - assert added_areas.status == "areas-added" - assert tagged.status == "tags-added" - assert updated_terms.status == "terms-updated" - assert tariff.task_id == "63" - assert cancelled_announcement_v1.status == "announcement-cancel-requested" - assert cancelled_parcel_v1.status == "sandbox-parcel-cancelled" - assert changed_parcel_v1.status == "change-created" - assert created_announcement_v1.status == "announcement-created-v1" - assert event_v1.status == "event-ready" - assert change_info_v1.status == "change-info-ready" - assert parcel_info_v1.status == "parcel-info-ready" - assert registered_parcel_id_v1.parcel_id == "reg-1" - assert sandbox_parcel.parcel_id == "spar-1" - assert task_info.status == "done" - - -def test_stock_management_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/stock-management/1/info": - assert json.loads(request.content.decode()) == {"itemIds": [123321]} - return httpx.Response( - 200, - json={ - "stocks": [ - { - "item_id": 123321, - "quantity": 5, - "is_multiple": True, - "is_unlimited": False, - "is_out_of_stock": False, - } - ] - }, - ) - assert request.url.path == "/stock-management/1/stocks" - assert request.method == "PUT" - assert json.loads(request.content.decode()) == { - "stocks": [{"item_id": 123321, "quantity": 7}] - } - return httpx.Response( - 200, - json={ - "stocks": [ - {"item_id": 123321, "external_id": "AB123456", "success": True, "errors": []} - ] - }, - ) - - stock = Stock(make_transport(httpx.MockTransport(handler)), resource_id="123321") - - info = stock.get(request=StockInfoRequest(item_ids=[123321])) - updated = stock.update( - request=StockUpdateRequest(stocks=[StockUpdateEntry(item_id=123321, quantity=7)]) - ) - - assert info.items[0].quantity == 5 - assert updated.items[0].external_id == "AB123456" - assert updated.items[0].success is True diff --git a/tests/test_stage8_jobs.py b/tests/test_stage8_jobs.py deleted file mode 100644 index c85735b..0000000 --- a/tests/test_stage8_jobs.py +++ /dev/null @@ -1,299 +0,0 @@ -from __future__ import annotations - -import json - -import httpx - -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts -from avito.jobs import Application, JobDictionary, JobWebhook, Resume, Vacancy -from avito.jobs.models import ( - ApplicationActionRequest, - ApplicationIdsQuery, - ApplicationIdsRequest, - ApplicationViewedItem, - ApplicationViewedRequest, - JobWebhookUpdateRequest, - ResumeSearchQuery, - VacancyArchiveRequest, - VacancyAutoRenewalRequest, - VacancyCreateRequest, - VacancyIdsRequest, - VacancyProlongateRequest, - VacancyUpdateRequest, -) - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_applications_and_webhooks_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - if path == "/job/v1/applications/get_ids": - assert request.url.params["updatedAtFrom"] == "2026-04-18" - return httpx.Response( - 200, - json={ - "items": [{"id": "app-1", "updatedAt": "2026-04-18T10:00:00+03:00"}], - "cursor": "app-1", - }, - ) - if path == "/job/v1/applications/get_by_ids": - assert json.loads(request.content.decode()) == {"ids": ["app-1"]} - return httpx.Response( - 200, - json={ - "applies": [ - { - "id": "app-1", - "vacancy_id": 101, - "state": "new", - "is_viewed": False, - "applicant": {"name": "Иван"}, - } - ] - }, - ) - if path == "/job/v1/applications/get_states": - return httpx.Response( - 200, json={"states": [{"slug": "new", "description": "Новый отклик"}]} - ) - if path == "/job/v1/applications/set_is_viewed": - assert json.loads(request.content.decode()) == { - "applies": [{"id": "app-1", "is_viewed": True}] - } - return httpx.Response(200, json={"ok": True, "status": "viewed"}) - if path == "/job/v1/applications/apply_actions": - assert json.loads(request.content.decode()) == {"ids": ["app-1"], "action": "invited"} - return httpx.Response(200, json={"ok": True, "status": "invited"}) - if path == "/job/v1/applications/webhook" and request.method == "GET": - return httpx.Response( - 200, json={"url": "https://example.com/job", "is_active": True, "version": "v1"} - ) - if path == "/job/v1/applications/webhook" and request.method == "PUT": - assert json.loads(request.content.decode()) == {"url": "https://example.com/job"} - return httpx.Response( - 200, json={"url": "https://example.com/job", "is_active": True, "version": "v1"} - ) - if path == "/job/v1/applications/webhook" and request.method == "DELETE": - assert request.url.params["url"] == "https://example.com/job" - return httpx.Response(200, json={"ok": True}) - assert path == "/job/v1/applications/webhooks" - return httpx.Response( - 200, json=[{"url": "https://example.com/job", "is_active": True, "version": "v1"}] - ) - - transport = make_transport(httpx.MockTransport(handler)) - application = Application(transport, resource_id="app-1") - webhook = JobWebhook(transport) - - ids = application.list(query=ApplicationIdsQuery(updated_at_from="2026-04-18")) - applications = application.list(request=ApplicationIdsRequest(ids=["app-1"])) - states = application.get_states() - viewed = application.update( - request=ApplicationViewedRequest( - applies=[ApplicationViewedItem(id="app-1", is_viewed=True)] - ) - ) - applied = application.apply(request=ApplicationActionRequest(ids=["app-1"], action="invited")) - current_hook = webhook.get() - updated_hook = webhook.update(request=JobWebhookUpdateRequest(url="https://example.com/job")) - deleted_hook = webhook.delete(url="https://example.com/job") - hooks = webhook.list() - - assert ids.items[0].id == "app-1" - assert applications.items[0].applicant_name == "Иван" - assert states.items[0].slug == "new" - assert viewed.status == "viewed" - assert applied.status == "invited" - assert current_hook.url == "https://example.com/job" - assert updated_hook.is_active is True - assert deleted_hook.success is True - assert hooks.items[0].version == "v1" - - -def test_resume_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/job/v1/resumes/": - assert request.url.params["query"] == "оператор" - return httpx.Response( - 200, - json={ - "meta": {"cursor": "2", "total": 1}, - "resumes": [ - { - "id": "res-1", - "title": "Оператор call-центра", - "name": "Петр", - "location": "Москва", - "salary": 90000, - } - ], - }, - ) - if request.url.path == "/job/v1/resumes/res-1/contacts/": - return httpx.Response( - 200, json={"name": "Петр", "phone": "+79990000000", "email": "petr@example.com"} - ) - assert request.url.path == "/job/v2/resumes/res-1" - return httpx.Response( - 200, - json={ - "id": "res-1", - "title": "Оператор call-центра", - "fullName": "Петр Петров", - "address_details": {"location": "Москва"}, - "salary": {"from": 90000}, - }, - ) - - resume = Resume(make_transport(httpx.MockTransport(handler)), resource_id="res-1") - - results = resume.list(query=ResumeSearchQuery(query="оператор")) - contacts = resume.get_contacts() - item = resume.get() - - assert results.cursor == "2" - assert results.items[0].candidate_name == "Петр" - assert contacts.phone == "+79990000000" - assert item.location == "Москва" - - -def test_vacancy_v1_v2_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - if path == "/job/v1/vacancies": - assert json.loads(request.content.decode()) == {"title": "Продавец"} - return httpx.Response(201, json={"id": 101, "status": "created"}) - if path == "/job/v1/vacancies/101": - assert request.method == "PUT" - assert json.loads(request.content.decode()) == {"title": "Старший продавец"} - return httpx.Response(200, json={"ok": True, "status": "updated"}) - if path == "/job/v1/vacancies/archived/101": - assert json.loads(request.content.decode()) == {"employee_id": 7} - return httpx.Response(200, json={"ok": True, "status": "archived"}) - if path == "/job/v1/vacancies/101/prolongate": - assert json.loads(request.content.decode()) == {"billing_type": "package"} - return httpx.Response(200, json={"ok": True, "status": "prolongated"}) - if path == "/job/v2/vacancies": - if request.method == "GET": - return httpx.Response( - 200, - json={ - "vacancies": [ - { - "id": 101, - "uuid": "vac-uuid-1", - "title": "Продавец", - "status": "active", - } - ], - "total": 1, - }, - ) - assert json.loads(request.content.decode()) == {"title": "Вакансия v2"} - return httpx.Response(202, json={"vacancy_uuid": "vac-uuid-1", "status": "created"}) - if path == "/job/v2/vacancies/batch": - assert json.loads(request.content.decode()) == {"ids": [101]} - return httpx.Response( - 200, - json={ - "vacancies": [ - {"id": 101, "uuid": "vac-uuid-1", "title": "Продавец", "status": "active"} - ] - }, - ) - if path == "/job/v2/vacancies/statuses": - assert json.loads(request.content.decode()) == {"ids": [101]} - return httpx.Response( - 200, json={"items": [{"id": 101, "uuid": "vac-uuid-1", "status": "active"}]} - ) - if path == "/job/v2/vacancies/update/vac-uuid-1": - assert json.loads(request.content.decode()) == {"title": "Вакансия v2 updated"} - return httpx.Response(202, json={"vacancy_uuid": "vac-uuid-1", "status": "updated"}) - if path == "/job/v2/vacancies/101": - return httpx.Response( - 200, - json={ - "id": 101, - "uuid": "vac-uuid-1", - "title": "Продавец", - "status": "active", - "url": "https://avito.ru/vacancy/101", - }, - ) - assert path == "/job/v2/vacancies/vac-uuid-1/auto_renewal" - assert json.loads(request.content.decode()) == {"auto_renewal": True} - return httpx.Response(200, json={"ok": True, "status": "auto-renewal-updated"}) - - vacancy = Vacancy(make_transport(httpx.MockTransport(handler)), resource_id="101") - - created_v1 = vacancy.create(request=VacancyCreateRequest(title="Продавец"), version=1) - updated_v1 = vacancy.update( - request=VacancyUpdateRequest(title="Старший продавец"), - version=1, - ) - archived_v1 = vacancy.delete(request=VacancyArchiveRequest(employee_id=7)) - prolonged_v1 = vacancy.prolongate(request=VacancyProlongateRequest(billing_type="package")) - list_v2 = vacancy.list() - created_v2 = vacancy.create(request=VacancyCreateRequest(title="Вакансия v2")) - batch_v2 = vacancy.get_by_ids(request=VacancyIdsRequest(ids=[101])) - statuses_v2 = vacancy.get_statuses(request=VacancyIdsRequest(ids=[101])) - updated_v2 = vacancy.update( - request=VacancyUpdateRequest(title="Вакансия v2 updated"), - version=2, - vacancy_uuid="vac-uuid-1", - ) - item_v2 = vacancy.get() - auto_renewal = vacancy.update_auto_renewal( - request=VacancyAutoRenewalRequest(auto_renewal=True), - vacancy_uuid="vac-uuid-1", - ) - - assert created_v1.id == "101" - assert updated_v1.status == "updated" - assert archived_v1.status == "archived" - assert prolonged_v1.status == "prolongated" - assert list_v2.items[0].uuid == "vac-uuid-1" - assert created_v2.id == "vac-uuid-1" - assert batch_v2.items[0].title == "Продавец" - assert statuses_v2.items[0].status == "active" - assert updated_v2.status == "updated" - assert item_v2.url == "https://avito.ru/vacancy/101" - assert auto_renewal.status == "auto-renewal-updated" - - -def test_job_dictionary_flows() -> None: - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/job/v2/vacancy/dict": - return httpx.Response(200, json=[{"id": "profession", "description": "Профессия"}]) - assert request.url.path == "/job/v2/vacancy/dict/profession" - return httpx.Response( - 200, json=[{"id": 10106, "name": "IT, интернет, телеком", "deprecated": True}] - ) - - dictionary = JobDictionary( - make_transport(httpx.MockTransport(handler)), resource_id="profession" - ) - - dictionaries = dictionary.list() - values = dictionary.get() - - assert dictionaries.items[0].id == "profession" - assert values.items[0].deprecated is True diff --git a/tests/test_stage8_serialization_contract.py b/tests/test_stage8_serialization_contract.py deleted file mode 100644 index d82ab59..0000000 --- a/tests/test_stage8_serialization_contract.py +++ /dev/null @@ -1,189 +0,0 @@ -from __future__ import annotations - -import importlib -import json -from dataclasses import is_dataclass -from inspect import isclass - -from avito.autoteka.models import CatalogField, CatalogFieldValue, CatalogResolveResult -from avito.core.types import BinaryResponse -from avito.cpa.models import CallTrackingRecord, CpaAudioRecord -from avito.messenger.models import SendMessageRequest -from avito.orders.models import LabelPdfResult -from avito.tariffs.models import TariffContractInfo, TariffInfo - -MODEL_MODULES = ( - "avito.accounts.models", - "avito.ads.models", - "avito.autoteka.models", - "avito.cpa.models", - "avito.jobs.models", - "avito.messenger.models", - "avito.orders.models", - "avito.promotion.models", - "avito.ratings.models", - "avito.realty.models", - "avito.tariffs.models", -) - - -def iter_model_classes() -> list[tuple[str, str, type[object]]]: - classes: list[tuple[str, str, type[object]]] = [] - for module_name in MODEL_MODULES: - module = importlib.import_module(module_name) - for name, value in vars(module).items(): - if not isclass(value) or getattr(value, "__module__", None) != module_name: - continue - if not is_dataclass(value): - continue - classes.append((module_name, name, value)) - return classes - - -def test_all_model_dataclasses_expose_standard_serialization_methods() -> None: - missing = [ - f"{module_name}:{name}" - for module_name, name, cls in iter_model_classes() - if not hasattr(cls, "to_dict") or not hasattr(cls, "model_dump") - ] - - assert missing == [] - - -def test_recursive_serialization_is_json_compatible_and_hides_transport_fields() -> None: - tariff = TariffInfo( - current=TariffContractInfo( - level="Максимальный", - is_active=True, - start_time=1713427200, - close_time=None, - bonus=10, - price=1990, - original_price=2490, - packages_count=2, - ), - scheduled=None, - ) - catalog = CatalogResolveResult( - items=[ - CatalogField( - field_id="brand", - label="Марка", - data_type="integer", - values=[ - CatalogFieldValue( - value_id="1", - label="Audi", - ) - ], - ) - ], - ) - request = SendMessageRequest(message="hello") - - assert tariff.to_dict() == { - "current": { - "level": "Максимальный", - "is_active": True, - "start_time": 1713427200, - "close_time": None, - "bonus": 10, - "price": 1990, - "original_price": 2490, - "packages_count": 2, - }, - "scheduled": None, - } - assert catalog.model_dump() == { - "items": [ - { - "field_id": "brand", - "label": "Марка", - "data_type": "integer", - "values": [{"value_id": "1", "label": "Audi"}], - } - ] - } - assert request.to_dict() == {"message": "hello", "type": None} - - json.dumps(tariff.to_dict()) - json.dumps(catalog.to_dict()) - json.dumps(request.to_dict()) - - -def test_ads_result_models_serialize_correctly() -> None: - from avito.ads.models import IdMappingResult, UpdatePriceResult - - r = UpdatePriceResult(item_id=42, price=999.0, status="active") - assert r.to_dict() == {"item_id": 42, "price": 999.0, "status": "active"} - json.dumps(r.to_dict()) - - m = IdMappingResult(mappings=[]) - assert m.to_dict() == {"mappings": []} - - -def test_autostrategy_models_serialize_correctly() -> None: - from avito.promotion.models import ( - AutostrategyBudget, - AutostrategyStat, - AutostrategyStatItem, - AutostrategyStatTotals, - CampaignActionResult, - CampaignsResult, - ) - - budget = AutostrategyBudget( - calc_id=1, recommended=None, minimal=None, maximal=None, price_ranges=[] - ) - assert budget.to_dict() == { - "calc_id": 1, - "recommended": None, - "minimal": None, - "maximal": None, - "price_ranges": [], - } - json.dumps(budget.to_dict()) - - result = CampaignActionResult(campaign=None) - assert result.to_dict() == {"campaign": None} - - campaigns = CampaignsResult(items=[], total_count=0) - assert campaigns.to_dict() == {"items": [], "total_count": 0} - - stat = AutostrategyStat( - items=[AutostrategyStatItem(date="2026-01-01", calls=5, views=10)], - totals=AutostrategyStatTotals(calls=5, views=10), - ) - dumped = stat.to_dict() - assert dumped["totals"] == {"calls": 5, "views": 10} - json.dumps(dumped) - - -def test_binary_result_models_serialize_without_transport_objects() -> None: - response = BinaryResponse( - content=b"\x00\x01payload", - content_type="application/octet-stream", - filename="artifact.bin", - status_code=200, - headers={"x-test": "1"}, - ) - - pdf = LabelPdfResult(binary=response) - audio = CpaAudioRecord(binary=response) - tracking = CallTrackingRecord(binary=response) - - expected = { - "filename": "artifact.bin", - "content_type": "application/octet-stream", - "content_base64": "AAFwYXlsb2Fk", - } - - assert pdf.to_dict() == expected - assert audio.model_dump() == expected - assert tracking.to_dict() == expected - - assert "binary" not in pdf.to_dict() - assert "status_code" not in pdf.to_dict() - assert "headers" not in pdf.to_dict() - - json.dumps(pdf.to_dict()) diff --git a/tests/test_stage9_cpa.py b/tests/test_stage9_cpa.py deleted file mode 100644 index ff03363..0000000 --- a/tests/test_stage9_cpa.py +++ /dev/null @@ -1,323 +0,0 @@ -from __future__ import annotations - -import json - -import httpx - -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts -from avito.cpa import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead -from avito.cpa.models import ( - CallTrackingCallsRequest, - CpaCallByIdRequest, - CpaCallComplaintRequest, - CpaCallsByTimeRequest, - CpaChatsByTimeRequest, - CpaLeadComplaintRequest, - CpaPhonesFromChatsRequest, -) - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_cpa_chat_and_phone_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 == "/cpa/v1/chatByActionId/act-1": - return httpx.Response( - 200, - json={ - "chat": { - "chat": { - "id": "chat-1", - "actionId": "act-1", - "createdAt": "2026-04-18T10:00:00+03:00", - "updatedAt": "2026-04-18T10:05:00+03:00", - }, - "buyer": {"userId": 501, "name": "Иван"}, - "item": {"id": 9001, "title": "Велосипед"}, - "isArbitrageAvailable": True, - } - }, - ) - if path == "/cpa/v1/chatsByTime": - assert payload == {"createdAtFrom": "2026-04-18T00:00:00+03:00"} - return httpx.Response( - 200, - json={ - "chats": [ - { - "chat": {"id": "chat-v1", "actionId": "legacy-1"}, - "buyer": {"userId": 502, "name": "Петр"}, - "item": {"id": 9002, "title": "Самокат"}, - "isArbitrageAvailable": False, - } - ] - }, - ) - if path == "/cpa/v2/chatsByTime": - assert payload == {"createdAtFrom": "2026-04-18T00:00:00+03:00", "limit": 10} - return httpx.Response( - 200, - json={ - "chats": [ - { - "chat": {"id": "chat-v2", "actionId": "act-2"}, - "buyer": {"userId": 503, "name": "Мария"}, - "item": {"id": 9003, "title": "Ноутбук"}, - "isArbitrageAvailable": True, - } - ] - }, - ) - assert path == "/cpa/v1/phonesInfoFromChats" - assert payload == {"actionIds": ["act-1", "act-2"]} - return httpx.Response( - 200, - json={ - "total": 2, - "results": [ - { - "id": 101, - "date": "2026-04-18T12:00:00+03:00", - "phone_number": "+79990000001", - "pricePenny": 1500, - "group": "Транспорт", - "url": "https://example.com/preview-1.jpg", - }, - { - "id": 102, - "date": "2026-04-18T12:05:00+03:00", - "phone_number": "+79990000002", - "pricePenny": 1700, - "group": "Электроника", - "url": "https://example.com/preview-2.jpg", - }, - ], - }, - ) - - chat = CpaChat(make_transport(httpx.MockTransport(handler)), resource_id="act-1") - - item = chat.get() - chats_v1 = chat.list( - request=CpaChatsByTimeRequest(created_at_from="2026-04-18T00:00:00+03:00"), - version=1, - ) - chats_v2 = chat.list( - request=CpaChatsByTimeRequest(created_at_from="2026-04-18T00:00:00+03:00", limit=10) - ) - phones = chat.get_phones_info_from_chats( - request=CpaPhonesFromChatsRequest(action_ids=["act-1", "act-2"]) - ) - - assert item.chat_id == "chat-1" - assert item.item_title == "Велосипед" - assert chats_v1.items[0].buyer_name == "Петр" - assert chats_v2.items[0].is_arbitrage_available is True - assert phones.total == 2 - assert phones.items[1].phone_number == "+79990000002" - - -def test_cpa_calls_balance_and_archive_flows() -> None: - audio_bytes = b"ID3 fake audio" - - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - payload = json.loads(request.content.decode()) if request.content else None - if path == "/cpa/v2/callsByTime": - assert payload == { - "dateTimeFrom": "2026-04-18T00:00:00+03:00", - "dateTimeTo": "2026-04-18T23:59:59+03:00", - } - return httpx.Response( - 200, - json={ - "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", - "startTime": "2026-04-18T10:59:30+03:00", - "groupTitle": "Автомобили", - "recordUrl": "https://example.com/record-2001.mp3", - "isArbitrageAvailable": True, - } - ] - }, - ) - if path == "/cpa/v1/createComplaint": - assert payload == {"callId": 2001, "reason": "spam"} - return httpx.Response(200, json={"success": True}) - if path == "/cpa/v1/createComplaintByActionId": - assert payload == {"actionId": "act-1", "reason": "duplicate"} - return httpx.Response(200, json={"success": True}) - if path == "/cpa/v3/balanceInfo": - assert payload == {} - return httpx.Response(200, json={"balance": -5000}) - if path == "/cpa/v2/balanceInfo": - assert payload == {} - return httpx.Response(200, json={"balance": -5000, "advance": 1000, "debt": 0}) - if path == "/cpa/v2/callById": - assert payload == {"callId": 2001} - return httpx.Response( - 200, - json={ - "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", - } - }, - ) - assert path == "/cpa/v1/call/2001" - return httpx.Response( - 200, - content=audio_bytes, - headers={ - "content-type": "audio/mpeg", - "content-disposition": 'attachment; filename="call-2001.mp3"', - }, - ) - - transport = make_transport(httpx.MockTransport(handler)) - cpa_call = CpaCall(transport, resource_id="2001") - cpa_lead = CpaLead(transport, resource_id="act-1") - archive = CpaArchive(transport, resource_id="2001") - - calls = cpa_call.list( - request=CpaCallsByTimeRequest( - date_time_from="2026-04-18T00:00:00+03:00", - date_time_to="2026-04-18T23:59:59+03:00", - ) - ) - complaint = cpa_call.create_complaint( - request=CpaCallComplaintRequest(call_id=2001, reason="spam") - ) - complaint_by_action = cpa_lead.create_complaint_by_action_id( - request=CpaLeadComplaintRequest(action_id="act-1", reason="duplicate") - ) - balance_v3 = cpa_lead.get_balance_info() - balance_v2 = archive.get_balance_info() - call_v2 = archive.get_call_by_id(request=CpaCallByIdRequest(call_id=2001)) - record = archive.get_call() - - assert calls.items[0].record_url == "https://example.com/record-2001.mp3" - assert complaint.success is True - assert complaint_by_action.success is True - assert balance_v3.balance == -5000 - assert balance_v2.advance == 1000 - assert call_v2.call_id == "2001" - assert record.filename == "call-2001.mp3" - assert record.binary.content == audio_bytes - - -def test_calltracking_flows() -> None: - audio_bytes = b"RIFF fake wave" - - def handler(request: httpx.Request) -> httpx.Response: - path = request.url.path - if path == "/calltracking/v1/getCallById/": - assert json.loads(request.content.decode()) == {"callId": 7001} - return httpx.Response( - 200, - json={ - "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": ""}, - }, - ) - if path == "/calltracking/v1/getCalls/": - assert json.loads(request.content.decode()) == { - "dateTimeFrom": "2026-04-01T00:00:00Z", - "dateTimeTo": "2026-04-18T23:59:59Z", - "limit": 100, - "offset": 0, - } - return httpx.Response( - 200, - json={ - "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": ""}, - }, - ) - assert path == "/calltracking/v1/getRecordByCallId/" - assert request.url.params["callId"] == "7001" - return httpx.Response( - 200, - content=audio_bytes, - headers={ - "content-type": "audio/wav", - "content-disposition": 'attachment; filename="record-7001.wav"', - }, - ) - - call = CallTrackingCall(make_transport(httpx.MockTransport(handler)), resource_id="7001") - - item = call.get() - items = call.list( - request=CallTrackingCallsRequest( - date_time_from="2026-04-01T00:00:00Z", - date_time_to="2026-04-18T23:59:59Z", - limit=100, - offset=0, - ) - ) - record = call.download() - - assert item.call.call_id == "7001" - assert item.call.talk_duration == 67 - assert item.error.code == 0 - assert items.items[0].buyer_phone == "+79990000100" - assert record.filename == "record-7001.wav" - assert record.binary.content == audio_bytes diff --git a/tests/test_stage9_transport_isolation.py b/tests/test_stage9_transport_isolation.py deleted file mode 100644 index d07e004..0000000 --- a/tests/test_stage9_transport_isolation.py +++ /dev/null @@ -1,231 +0,0 @@ -from __future__ import annotations - -import inspect -import json - -import httpx - -from avito.accounts import Account, AccountProfile -from avito.accounts.mappers import map_account_profile -from avito.ads import Ad, Listing -from avito.ads.mappers import map_ad_item -from avito.auth import AuthSettings -from avito.config import AvitoSettings -from avito.core import Transport -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts -from avito.promotion import BbipPromotion, PromotionOrder, PromotionService, PromotionServicesResult -from avito.promotion.mappers import map_promotion_services - - -def make_transport(handler: httpx.MockTransport) -> Transport: - settings = AvitoSettings( - base_url="https://api.avito.ru", - auth=AuthSettings(), - retry_policy=RetryPolicy(), - timeouts=ApiTimeouts(), - ) - return Transport( - settings, - auth_provider=None, - client=httpx.Client(transport=handler, base_url="https://api.avito.ru"), - sleep=lambda _: None, - ) - - -def test_public_packages_do_not_export_transport_shapes_or_mappers() -> None: - import avito.accounts as accounts - import avito.ads as ads - import avito.cpa as cpa - import avito.jobs as jobs - import avito.orders as orders - import avito.promotion as promotion - - for module in (accounts, ads, cpa, jobs, orders, promotion): - exported_names = set(getattr(module, "__all__", ())) - assert "JsonRequest" not in exported_names - assert "Transport" not in exported_names - assert not any(name.startswith("map_") for name in exported_names) - assert not any(name.endswith("Client") for name in exported_names) - - -def test_public_domain_signatures_hide_internal_request_wrappers() -> None: - methods = ( - Account.get_self, - Ad.get, - Ad.list, - PromotionOrder.list_services, - PromotionOrder.list_orders, - PromotionOrder.get_order_status, - BbipPromotion.get_suggests, - ) - banned_tokens = ("JsonRequest", "CreateBbip", "ListPromotion", "GetPromotionOrderStatusRequest") - - for method in methods: - signature_text = str(inspect.signature(method)) - doc_text = inspect.getdoc(method) or "" - public_text = f"{signature_text}\n{doc_text}" - for token in banned_tokens: - assert token not in public_text - - -def test_public_methods_return_sdk_models_not_transport_shapes() -> 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 == "/core/v1/accounts/self": - return httpx.Response(200, json={"user_id": 7, "title": "Shop 7"}) - if path == "/core/v1/accounts/7/items/101/": - return httpx.Response( - 200, - json={ - "item_id": 101, - "userId": 7, - "title": "Ноутбук", - "price": 55000, - "link": "https://example.test/items/101", - }, - ) - assert path == "/promotion/v1/items/services/get" - assert payload == {"itemIds": [101]} - return httpx.Response( - 200, - json={ - "services": [ - { - "itemID": 101, - "code": "xl", - "name": "XL", - "pricePenny": 12900, - "status": "available", - } - ] - }, - ) - - transport = make_transport(httpx.MockTransport(handler)) - - profile = Account(transport, user_id=7).get_self() - listing = Ad(transport, resource_id=101, user_id=7).get() - services = PromotionOrder(transport).list_services(item_ids=[101]) - - assert isinstance(profile, AccountProfile) - assert isinstance(listing, Listing) - assert isinstance(services, PromotionServicesResult) - assert isinstance(services.items[0], PromotionService) - - assert profile.to_dict() == {"id": 7, "name": "Shop 7", "email": None, "phone": None} - assert listing.to_dict() == { - "id": 101, - "user_id": 7, - "title": "Ноутбук", - "description": None, - "status": None, - "price": 55000.0, - "url": "https://example.test/items/101", - } - assert services.to_dict() == { - "items": [ - { - "item_id": 101, - "service_code": "xl", - "service_name": "XL", - "price": 12900, - "status": "available", - } - ] - } - assert "_payload" not in profile.to_dict() - assert "_payload" not in listing.to_dict() - assert "_payload" not in services.to_dict() - - -def test_mappers_keep_stable_contract_for_happy_and_partial_payloads() -> None: - happy_profile = map_account_profile( - {"id": 7, "name": "Main shop", "email": "shop@example.test"} - ) - partial_profile = map_account_profile({"user_id": 7, "title": "Main shop"}) - - happy_listing = map_ad_item( - { - "id": 101, - "user_id": 7, - "title": "Phone", - "description": "Flagship", - "status": "active", - "price": 99990, - "url": "https://example.test/items/101", - } - ) - partial_listing = map_ad_item({"itemId": 101, "userId": 7, "title": "Phone"}) - - happy_services = map_promotion_services( - { - "items": [ - { - "itemId": 101, - "serviceCode": "highlight", - "serviceName": "Highlight", - "price": 4900, - "status": "active", - } - ] - } - ) - partial_services = map_promotion_services({"services": [{"itemID": 101, "code": "highlight"}]}) - - assert happy_profile.to_dict() == { - "id": 7, - "name": "Main shop", - "email": "shop@example.test", - "phone": None, - } - assert partial_profile.to_dict() == { - "id": 7, - "name": "Main shop", - "email": None, - "phone": None, - } - - assert happy_listing.to_dict() == { - "id": 101, - "user_id": 7, - "title": "Phone", - "description": "Flagship", - "status": "active", - "price": 99990.0, - "url": "https://example.test/items/101", - } - assert partial_listing.to_dict() == { - "id": 101, - "user_id": 7, - "title": "Phone", - "description": None, - "status": None, - "price": None, - "url": None, - } - - assert happy_services.to_dict() == { - "items": [ - { - "item_id": 101, - "service_code": "highlight", - "service_name": "Highlight", - "price": 4900, - "status": "active", - } - ] - } - assert partial_services.to_dict() == { - "items": [ - { - "item_id": 101, - "service_code": "highlight", - "service_name": None, - "price": None, - "status": None, - } - ] - } From d927f818e197f5b84664609510d2f6067af6c317 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Tue, 21 Apr 2026 01:11:53 +0300 Subject: [PATCH 16/17] =?UTF-8?q?=D0=A2=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 32 +++++++++++++++----------------- docs/inventory.md | 2 +- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 4c39d45..9ec1589 100644 --- a/README.md +++ b/README.md @@ -75,14 +75,14 @@ client = AvitoClient.from_env() - `AVITO_USER_ID` - `AVITO_AUTH__CLIENT_ID`, alias: `AVITO_CLIENT_ID` - `AVITO_AUTH__CLIENT_SECRET`, alias: `AVITO_CLIENT_SECRET` -- `AVITO_AUTH__REFRESH_TOKEN`, alias: `AVITO_REFRESH_TOKEN`, `REFRESH_TOKEN` -- `AVITO_AUTH__SCOPE`, alias: `AVITO_SCOPE`, `SCOPE` -- `AVITO_AUTH__TOKEN_URL`, alias: `AVITO_TOKEN_URL`, `TOKEN_URL` -- `AVITO_AUTH__ALTERNATE_TOKEN_URL`, alias: `AVITO_ALTERNATE_TOKEN_URL`, `ALTERNATE_TOKEN_URL` -- `AVITO_AUTH__AUTOTEKA_TOKEN_URL`, alias: `AVITO_AUTOTEKA_TOKEN_URL`, `AUTOTEKA_TOKEN_URL` -- `AVITO_AUTH__AUTOTEKA_CLIENT_ID`, alias: `AVITO_AUTOTEKA_CLIENT_ID`, `AUTOTEKA_CLIENT_ID` -- `AVITO_AUTH__AUTOTEKA_CLIENT_SECRET`, alias: `AVITO_AUTOTEKA_CLIENT_SECRET`, `AUTOTEKA_CLIENT_SECRET` -- `AVITO_AUTH__AUTOTEKA_SCOPE`, alias: `AVITO_AUTOTEKA_SCOPE`, `AUTOTEKA_SCOPE` +- `AVITO_AUTH__REFRESH_TOKEN`, alias: `AVITO_REFRESH_TOKEN` +- `AVITO_AUTH__SCOPE`, alias: `AVITO_SCOPE` +- `AVITO_AUTH__TOKEN_URL`, alias: `AVITO_TOKEN_URL` +- `AVITO_AUTH__ALTERNATE_TOKEN_URL`, alias: `AVITO_ALTERNATE_TOKEN_URL` +- `AVITO_AUTH__AUTOTEKA_TOKEN_URL`, alias: `AVITO_AUTOTEKA_TOKEN_URL` +- `AVITO_AUTH__AUTOTEKA_CLIENT_ID`, alias: `AVITO_AUTOTEKA_CLIENT_ID` +- `AVITO_AUTH__AUTOTEKA_CLIENT_SECRET`, alias: `AVITO_AUTOTEKA_CLIENT_SECRET` +- `AVITO_AUTH__AUTOTEKA_SCOPE`, alias: `AVITO_AUTOTEKA_SCOPE` Правила resolution: @@ -122,7 +122,9 @@ from avito.messenger import UploadImageFile with AvitoClient() as avito: chats = avito.chat(user_id=123).list() - message = avito.chat(chat_id="chat-1", user_id=123).send_message(message="Здравствуйте") + message = avito.chat_message(chat_id="chat-1", user_id=123).send_message( + message="Здравствуйте" + ) uploaded = avito.chat_media(user_id=123).upload_images( files=[ UploadImageFile( @@ -142,18 +144,14 @@ with AvitoClient() as avito: from avito import AvitoClient from datetime import datetime -from avito.promotion.models import CreateAutostrategyBudgetRequest - with AvitoClient() as avito: services = avito.promotion_order().list_orders() forecast = avito.bbip_promotion(item_id=42).get_forecasts(items=[]) budget = avito.autostrategy_campaign().create_budget( - request=CreateAutostrategyBudgetRequest( - campaign_type="AS", - start_time="2026-04-20T00:00:00Z", - finish_time="2026-04-27T00:00:00Z", - items=[42, 43], - ) + campaign_type="AS", + start_time="2026-04-20T00:00:00Z", + finish_time="2026-04-27T00:00:00Z", + items=[42, 43], ) campaign = avito.autostrategy_campaign(campaign_id=15).get() campaigns = avito.autostrategy_campaign().list( diff --git a/docs/inventory.md b/docs/inventory.md index 0683996..264fbf9 100644 --- a/docs/inventory.md +++ b/docs/inventory.md @@ -1,6 +1,6 @@ # Инвентарь Swagger -Инвентарь фиксирует покрытие этапа 1 для всех операций из `docs/*.json` и служит источником истины для теста `tests/test_inventory.py`. +Инвентарь фиксирует покрытие этапа 1 для всех операций из `docs/*.json` и служит источником истины для развития публичного API SDK, доменных тестов и документации репозитория. - Всего swagger-документов: 23. - Всего операций: 204. From 1be33711c31f8ebd80a39887ad79bf0d63582a5c Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Tue, 21 Apr 2026 10:10:48 +0300 Subject: [PATCH 17/17] =?UTF-8?q?=D0=9F=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- avito/__init__.py | 14 +- avito/accounts/client.py | 3 +- avito/accounts/mappers.py | 2 +- avito/ads/__init__.py | 2 +- avito/ads/client.py | 11 +- avito/ads/domain.py | 9 +- avito/ads/mappers.py | 2 +- avito/auth/__init__.py | 21 +- avito/autoteka/domain.py | 92 +++--- avito/client.py | 17 +- avito/config.py | 12 - avito/core/__init__.py | 101 +++++-- avito/core/exceptions.py | 15 - avito/core/retries.py | 40 ++- avito/core/transport.py | 26 +- avito/core/types.py | 3 +- avito/cpa/domain.py | 39 ++- avito/jobs/domain.py | 29 +- avito/orders/domain.py | 345 ++++++++++++++++------ avito/promotion/__init__.py | 4 +- avito/promotion/domain.py | 23 +- avito/promotion/mappers.py | 4 +- avito/promotion/models.py | 10 +- avito/realty/domain.py | 15 +- avito/settings.py | 8 +- tests/contracts/test_client_contracts.py | 33 ++- tests/contracts/test_model_contracts.py | 2 +- tests/domains/autoteka/test_autoteka.py | 54 ++-- tests/domains/cpa/test_cpa.py | 12 +- tests/domains/jobs/test_jobs.py | 21 +- tests/domains/orders/test_orders.py | 44 +-- tests/domains/promotion/test_promotion.py | 35 ++- tests/domains/realty/test_realty.py | 3 +- 33 files changed, 679 insertions(+), 372 deletions(-) diff --git a/avito/__init__.py b/avito/__init__.py index b0ed1b6..d4de0b3 100644 --- a/avito/__init__.py +++ b/avito/__init__.py @@ -1,19 +1,7 @@ """Публичные экспорты пакета SDK для Avito.""" -from typing import TYPE_CHECKING - +from avito.auth.settings import AuthSettings from avito.client import AvitoClient from avito.config import AvitoSettings -if TYPE_CHECKING: - from avito.auth.settings import AuthSettings - __all__ = ("AuthSettings", "AvitoClient", "AvitoSettings") - - -def __getattr__(name: str) -> object: - if name == "AuthSettings": - from avito.auth.settings import AuthSettings - - return AuthSettings - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/avito/accounts/client.py b/avito/accounts/client.py index d7b5889..d1f72e7 100644 --- a/avito/accounts/client.py +++ b/avito/accounts/client.py @@ -15,9 +15,9 @@ map_operations_history, ) from avito.accounts.models import ( + AccountActionResult, AccountBalance, AccountProfile, - AccountActionResult, AhUserStatus, CompanyPhonesResult, EmployeeItem, @@ -26,7 +26,6 @@ EmployeesResult, OperationRecord, OperationsHistoryRequest, - OperationsHistoryResult, ) from avito.core import JsonPage, PaginatedList, Paginator, RequestContext, Transport from avito.core.mapping import request_public_model diff --git a/avito/accounts/mappers.py b/avito/accounts/mappers.py index 2d9ad72..418495a 100644 --- a/avito/accounts/mappers.py +++ b/avito/accounts/mappers.py @@ -7,9 +7,9 @@ from typing import cast from avito.accounts.models import ( + AccountActionResult, AccountBalance, AccountProfile, - AccountActionResult, AhUserStatus, CompanyPhone, CompanyPhonesResult, diff --git a/avito/ads/__init__.py b/avito/ads/__init__.py index c76d8e4..3375431 100644 --- a/avito/ads/__init__.py +++ b/avito/ads/__init__.py @@ -24,8 +24,8 @@ AutoloadReportSummary, AutoloadTreeNode, AutoloadTreeResult, - CallStats, CallsStatsResult, + CallStats, ItemAnalyticsResult, ItemStatsResult, LegacyAutoloadReport, diff --git a/avito/ads/client.py b/avito/ads/client.py index 039b322..2991767 100644 --- a/avito/ads/client.py +++ b/avito/ads/client.py @@ -28,7 +28,6 @@ from avito.ads.models import ( AccountSpendings, AdsActionResult, - AdsListResult, ApplyVasPackageRequest, ApplyVasRequest, AutoloadFeesResult, @@ -37,7 +36,6 @@ AutoloadProfileUpdateRequest, AutoloadReportDetails, AutoloadReportItemsResult, - AutoloadReportsResult, AutoloadReportSummary, AutoloadTreeResult, CallsStatsRequest, @@ -55,7 +53,14 @@ VasPricesRequest, VasPricesResult, ) -from avito.core import JsonPage, PaginatedList, Paginator, RequestContext, Transport, ValidationError +from avito.core import ( + JsonPage, + PaginatedList, + Paginator, + RequestContext, + Transport, + ValidationError, +) from avito.core.mapping import request_public_model from avito.promotion.mappers import map_promotion_action from avito.promotion.models import PromotionActionResult diff --git a/avito/ads/domain.py b/avito/ads/domain.py index 260ba2f..837f65f 100644 --- a/avito/ads/domain.py +++ b/avito/ads/domain.py @@ -41,10 +41,9 @@ VasPricesRequest, VasPricesResult, ) -from avito.core import PaginatedList, Transport, ValidationError +from avito.core import PaginatedList, ValidationError from avito.core.domain import DomainObject from avito.core.validation import ( - validate_non_empty, validate_non_empty_string, validate_string_items, ) @@ -250,7 +249,7 @@ def apply_vas( validate_string_items("codes", codes) request = ApplyVasRequest(codes=codes) request_payload = request.to_payload() - target = {"item_id": item_id, "user_id": user_id} + target: dict[str, object] = {"item_id": item_id, "user_id": user_id} if dry_run: return _preview_result( action="apply_vas", @@ -275,7 +274,7 @@ def apply_vas_package( validate_non_empty_string("package_code", package_code) request = ApplyVasPackageRequest(package_code=package_code) request_payload = request.to_payload() - target = {"item_id": item_id, "user_id": user_id} + target: dict[str, object] = {"item_id": item_id, "user_id": user_id} if dry_run: return _preview_result( action="apply_vas_package", @@ -300,7 +299,7 @@ def apply_vas_direct( validate_string_items("codes", codes) request = ApplyVasRequest(codes=codes) request_payload = request.to_payload() - target = {"item_id": item_id} + target: dict[str, object] = {"item_id": item_id} if dry_run: return _preview_result( action="apply_vas_direct", diff --git a/avito/ads/mappers.py b/avito/ads/mappers.py index 9cf9967..e3819b4 100644 --- a/avito/ads/mappers.py +++ b/avito/ads/mappers.py @@ -22,8 +22,8 @@ AutoloadReportSummary, AutoloadTreeNode, AutoloadTreeResult, - CallStats, CallsStatsResult, + CallStats, IdMappingResult, ItemAnalyticsResult, ItemStatsResult, diff --git a/avito/auth/__init__.py b/avito/auth/__init__.py index df3f76d..03d66d3 100644 --- a/avito/auth/__init__.py +++ b/avito/auth/__init__.py @@ -1,14 +1,20 @@ """Пакет аутентификации.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from avito.auth.models import ( AccessToken, ClientCredentialsRequest, RefreshTokenRequest, TokenResponse, ) -from avito.auth.provider import AlternateTokenClient, AuthProvider, TokenClient from avito.auth.settings import AuthSettings +if TYPE_CHECKING: + from avito.auth.provider import AlternateTokenClient, AuthProvider, TokenClient + __all__ = ( "AccessToken", "AlternateTokenClient", @@ -19,3 +25,16 @@ "TokenClient", "TokenResponse", ) + + +def __getattr__(name: str) -> object: + if name in {"AlternateTokenClient", "AuthProvider", "TokenClient"}: + from avito.auth.provider import AlternateTokenClient, AuthProvider, TokenClient + + exports = { + "AlternateTokenClient": AlternateTokenClient, + "AuthProvider": AuthProvider, + "TokenClient": TokenClient, + } + return exports[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/avito/autoteka/domain.py b/avito/autoteka/domain.py index d766087..e2fb0e8 100644 --- a/avito/autoteka/domain.py +++ b/avito/autoteka/domain.py @@ -53,42 +53,48 @@ class AutotekaVehicle(DomainObject): vehicle_id: int | str | None = None user_id: int | str | None = None - def resolve_catalog(self, *, request: CatalogResolveRequest) -> CatalogResolveResult: + def resolve_catalog(self, *, brand_id: int) -> CatalogResolveResult: """Актуализирует параметры автокаталога.""" - return CatalogClient(self.transport).resolve_catalog(request) + return CatalogClient(self.transport).resolve_catalog(CatalogResolveRequest(brand_id=brand_id)) - def get_leads(self, *, request: LeadsRequest) -> AutotekaLeadsResult: - return LeadsClient(self.transport).get_leads(request) + def get_leads(self, *, limit: int) -> AutotekaLeadsResult: + return LeadsClient(self.transport).get_leads(LeadsRequest(limit=limit)) - def create_preview_by_vin(self, *, request: VinRequest) -> AutotekaPreviewInfo: - return PreviewClient(self.transport).create_by_vin(request) + def create_preview_by_vin(self, *, vin: str) -> AutotekaPreviewInfo: + return PreviewClient(self.transport).create_by_vin(VinRequest(vin=vin)) def get_preview(self, *, preview_id: int | str | None = None) -> AutotekaPreviewInfo: return PreviewClient(self.transport).get_preview( preview_id=preview_id or self._require_vehicle_id("preview_id") ) - def create_preview_by_external_item( - self, *, request: ExternalItemPreviewRequest - ) -> AutotekaPreviewInfo: - return PreviewClient(self.transport).create_by_external_item(request) + def create_preview_by_external_item(self, *, item_id: str, site: str) -> AutotekaPreviewInfo: + return PreviewClient(self.transport).create_by_external_item( + ExternalItemPreviewRequest(item_id=item_id, site=site) + ) - def create_preview_by_item_id(self, *, request: ItemIdRequest) -> AutotekaPreviewInfo: - return PreviewClient(self.transport).create_by_item_id(request) + def create_preview_by_item_id(self, *, item_id: int) -> AutotekaPreviewInfo: + return PreviewClient(self.transport).create_by_item_id(ItemIdRequest(item_id=item_id)) - def create_preview_by_reg_number(self, *, request: RegNumberRequest) -> AutotekaPreviewInfo: - return PreviewClient(self.transport).create_by_reg_number(request) + def create_preview_by_reg_number(self, *, reg_number: str) -> AutotekaPreviewInfo: + return PreviewClient(self.transport).create_by_reg_number( + RegNumberRequest(reg_number=reg_number) + ) def create_specification_by_plate_number( - self, *, request: PlateNumberRequest + self, *, plate_number: str ) -> AutotekaSpecificationInfo: - return SpecificationsClient(self.transport).create_by_plate_number(request) + return SpecificationsClient(self.transport).create_by_plate_number( + PlateNumberRequest(plate_number=plate_number) + ) def create_specification_by_vehicle_id( - self, *, request: VehicleIdRequest + self, *, vehicle_id: str ) -> AutotekaSpecificationInfo: - return SpecificationsClient(self.transport).create_by_vehicle_id(request) + return SpecificationsClient(self.transport).create_by_vehicle_id( + VehicleIdRequest(vehicle_id=vehicle_id) + ) def get_specification_by_id( self, @@ -99,8 +105,8 @@ def get_specification_by_id( specification_id=specification_id or self._require_vehicle_id("specification_id") ) - def create_teaser(self, *, request: TeaserCreateRequest) -> AutotekaTeaserInfo: - return TeaserClient(self.transport).create(request) + def create_teaser(self, *, vehicle_id: str) -> AutotekaTeaserInfo: + return TeaserClient(self.transport).create(TeaserCreateRequest(vehicle_id=vehicle_id)) def get_teaser(self, *, teaser_id: int | str | None = None) -> AutotekaTeaserInfo: return TeaserClient(self.transport).get( @@ -123,11 +129,13 @@ class AutotekaReport(DomainObject): def get_active_package(self) -> AutotekaPackageInfo: return ReportClient(self.transport).get_active_package() - def create_report(self, *, request: PreviewReportRequest) -> AutotekaReportInfo: - return ReportClient(self.transport).create_report(request) + def create_report(self, *, preview_id: int) -> AutotekaReportInfo: + return ReportClient(self.transport).create_report(PreviewReportRequest(preview_id=preview_id)) - def create_report_by_vehicle_id(self, *, request: VehicleIdRequest) -> AutotekaReportInfo: - return ReportClient(self.transport).create_report_by_vehicle_id(request) + def create_report_by_vehicle_id(self, *, vehicle_id: str) -> AutotekaReportInfo: + return ReportClient(self.transport).create_report_by_vehicle_id( + VehicleIdRequest(vehicle_id=vehicle_id) + ) def list_reports(self) -> AutotekaReportsResult: """Получает список отчетов Автотеки.""" @@ -139,11 +147,13 @@ def get_report(self, *, report_id: int | str | None = None) -> AutotekaReportInf report_id=report_id or self._require_report_id() ) - def create_sync_report_by_reg_number(self, *, request: RegNumberRequest) -> AutotekaReportInfo: - return ReportClient(self.transport).create_sync_report_by_reg_number(request) + def create_sync_report_by_reg_number(self, *, reg_number: str) -> AutotekaReportInfo: + return ReportClient(self.transport).create_sync_report_by_reg_number( + RegNumberRequest(reg_number=reg_number) + ) - def create_sync_report_by_vin(self, *, request: VinRequest) -> AutotekaReportInfo: - return ReportClient(self.transport).create_sync_report_by_vin(request) + def create_sync_report_by_vin(self, *, vin: str) -> AutotekaReportInfo: + return ReportClient(self.transport).create_sync_report_by_vin(VinRequest(vin=vin)) def _require_report_id(self) -> str: if self.report_id is None: @@ -157,20 +167,22 @@ class AutotekaMonitoring(DomainObject): user_id: int | str | None = None - def create_monitoring_bucket_add( - self, *, request: MonitoringBucketRequest - ) -> MonitoringBucketResult: - return MonitoringClient(self.transport).add_bucket(request) + def create_monitoring_bucket_add(self, *, vehicles: list[str]) -> MonitoringBucketResult: + return MonitoringClient(self.transport).add_bucket( + MonitoringBucketRequest(vehicles=vehicles) + ) def delete_bucket(self) -> MonitoringBucketResult: """Очищает bucket мониторинга.""" return MonitoringClient(self.transport).delete_bucket() - def remove_bucket(self, *, request: MonitoringBucketRequest) -> MonitoringBucketResult: + def remove_bucket(self, *, vehicles: list[str]) -> MonitoringBucketResult: """Удаляет автомобили из bucket мониторинга.""" - return MonitoringClient(self.transport).remove_bucket(request) + return MonitoringClient(self.transport).remove_bucket( + MonitoringBucketRequest(vehicles=vehicles) + ) def get_monitoring_reg_actions( self, @@ -187,8 +199,10 @@ class AutotekaScoring(DomainObject): scoring_id: int | str | None = None user_id: int | str | None = None - def create_scoring_by_vehicle_id(self, *, request: VehicleIdRequest) -> AutotekaScoringInfo: - return ScoringClient(self.transport).create_by_vehicle_id(request) + def create_scoring_by_vehicle_id(self, *, vehicle_id: str) -> AutotekaScoringInfo: + return ScoringClient(self.transport).create_by_vehicle_id( + VehicleIdRequest(vehicle_id=vehicle_id) + ) def get_scoring_by_id(self, *, scoring_id: int | str | None = None) -> AutotekaScoringInfo: return ScoringClient(self.transport).get_by_id( @@ -208,9 +222,11 @@ class AutotekaValuation(DomainObject): user_id: int | str | None = None def get_valuation_by_specification( - self, *, request: ValuationBySpecificationRequest + self, *, specification_id: int, mileage: int ) -> AutotekaValuationInfo: - return ValuationClient(self.transport).get_by_specification(request) + return ValuationClient(self.transport).get_by_specification( + ValuationBySpecificationRequest(specification_id=specification_id, mileage=mileage) + ) __all__ = ( diff --git a/avito/client.py b/avito/client.py index 0deb889..4807762 100644 --- a/avito/client.py +++ b/avito/client.py @@ -19,6 +19,7 @@ ) from avito.config import AvitoSettings from avito.core import Transport, TransportDebugInfo +from avito.core.transport import build_httpx_timeout from avito.cpa import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead from avito.jobs import Application, JobDictionary, JobWebhook, Resume, Vacancy from avito.messenger import Chat, ChatMedia, ChatMessage, ChatWebhook, SpecialOfferCampaign @@ -103,9 +104,19 @@ def __exit__( self.close() def _build_auth_provider(self) -> AuthProvider: - token_http_client = httpx.Client(base_url=self.settings.base_url.rstrip("/")) - alternate_http_client = httpx.Client(base_url=self.settings.base_url.rstrip("/")) - autoteka_http_client = httpx.Client(base_url=self.settings.base_url.rstrip("/")) + timeout = build_httpx_timeout(self.settings.timeouts) + token_http_client = httpx.Client( + base_url=self.settings.base_url.rstrip("/"), + timeout=timeout, + ) + alternate_http_client = httpx.Client( + base_url=self.settings.base_url.rstrip("/"), + timeout=timeout, + ) + autoteka_http_client = httpx.Client( + base_url=self.settings.base_url.rstrip("/"), + timeout=timeout, + ) return AuthProvider( self.settings.auth, token_client=TokenClient(self.settings.auth, client=token_http_client), diff --git a/avito/config.py b/avito/config.py index 59089d2..551b2ae 100644 --- a/avito/config.py +++ b/avito/config.py @@ -27,18 +27,6 @@ class AvitoSettings: timeouts: ApiTimeouts = field(default_factory=ApiTimeouts) retry_policy: RetryPolicy = field(default_factory=RetryPolicy) - @property - def client_id(self) -> str | None: - """Возвращает `client_id` для совместимости со старым API.""" - - return self.auth.client_id - - @property - def client_secret(self) -> str | None: - """Возвращает `client_secret` для совместимости со старым API.""" - - return self.auth.client_secret - @classmethod def from_env(cls, *, env_file: str | Path | None = ".env") -> AvitoSettings: """Загружает конфигурацию из окружения и optional `.env` файла.""" diff --git a/avito/core/__init__.py b/avito/core/__init__.py index 928fd25..c0f7b94 100644 --- a/avito/core/__init__.py +++ b/avito/core/__init__.py @@ -1,34 +1,39 @@ """Пакет общей инфраструктуры SDK.""" -from avito.core.domain import DomainObject -from avito.core.exceptions import ( - AuthenticationError, - AuthorizationError, - AvitoError, - ClientError, - ConfigurationError, - ConflictError, - NotFoundError, - PermissionDeniedError, - RateLimitError, - ResponseMappingError, - ServerError, - TransportError, - UnsupportedOperationError, - UpstreamApiError, - ValidationError, -) -from avito.core.pagination import PaginatedList, Paginator -from avito.core.retries import RetryDecision, RetryPolicy -from avito.core.serialization import SerializableModel -from avito.core.transport import Transport -from avito.core.types import ( - ApiTimeouts, - BinaryResponse, - JsonPage, - RequestContext, - TransportDebugInfo, -) +from __future__ import annotations + +from importlib import import_module +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from avito.core.domain import DomainObject + from avito.core.exceptions import ( + AuthenticationError, + AuthorizationError, + AvitoError, + ClientError, + ConfigurationError, + ConflictError, + NotFoundError, + RateLimitError, + ResponseMappingError, + ServerError, + TransportError, + UnsupportedOperationError, + UpstreamApiError, + ValidationError, + ) + from avito.core.pagination import PaginatedList, Paginator + from avito.core.retries import RetryDecision, RetryPolicy + from avito.core.serialization import SerializableModel + from avito.core.transport import Transport + from avito.core.types import ( + ApiTimeouts, + BinaryResponse, + JsonPage, + RequestContext, + TransportDebugInfo, + ) __all__ = ( "ApiTimeouts", @@ -44,7 +49,6 @@ "NotFoundError", "PaginatedList", "Paginator", - "PermissionDeniedError", "RateLimitError", "RequestContext", "ResponseMappingError", @@ -59,3 +63,40 @@ "UpstreamApiError", "ValidationError", ) + +_EXPORT_MODULES = { + "ApiTimeouts": "avito.core.types", + "AuthenticationError": "avito.core.exceptions", + "AuthorizationError": "avito.core.exceptions", + "AvitoError": "avito.core.exceptions", + "BinaryResponse": "avito.core.types", + "ClientError": "avito.core.exceptions", + "ConfigurationError": "avito.core.exceptions", + "ConflictError": "avito.core.exceptions", + "DomainObject": "avito.core.domain", + "JsonPage": "avito.core.types", + "NotFoundError": "avito.core.exceptions", + "PaginatedList": "avito.core.pagination", + "Paginator": "avito.core.pagination", + "RateLimitError": "avito.core.exceptions", + "RequestContext": "avito.core.types", + "ResponseMappingError": "avito.core.exceptions", + "RetryDecision": "avito.core.retries", + "RetryPolicy": "avito.core.retries", + "SerializableModel": "avito.core.serialization", + "ServerError": "avito.core.exceptions", + "Transport": "avito.core.transport", + "TransportDebugInfo": "avito.core.types", + "TransportError": "avito.core.exceptions", + "UnsupportedOperationError": "avito.core.exceptions", + "UpstreamApiError": "avito.core.exceptions", + "ValidationError": "avito.core.exceptions", +} + + +def __getattr__(name: str) -> object: + module_name = _EXPORT_MODULES.get(name) + if module_name is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module = import_module(module_name) + return getattr(module, name) diff --git a/avito/core/exceptions.py b/avito/core/exceptions.py index 4eacd81..e03848c 100644 --- a/avito/core/exceptions.py +++ b/avito/core/exceptions.py @@ -101,20 +101,6 @@ class AuthorizationError(AvitoError): """Ошибка авторизации: недостаточно прав для операции (HTTP 403).""" -class PermissionDeniedError(AuthorizationError): - """Устаревший псевдоним `AuthorizationError`. Используйте `AuthorizationError` напрямую.""" - - def __init__(self, *args: object, **kwargs: object) -> None: - import warnings - - warnings.warn( - "PermissionDeniedError устарел и будет удалён. Используйте AuthorizationError.", - DeprecationWarning, - stacklevel=2, - ) - super().__init__(*args, **kwargs) - - class ValidationError(AvitoError): """API отклонил запрос из-за некорректных параметров (HTTP 400, 422).""" @@ -163,7 +149,6 @@ class ResponseMappingError(AvitoError): "ConfigurationError", "ConflictError", "NotFoundError", - "PermissionDeniedError", "RateLimitError", "ResponseMappingError", "ServerError", diff --git a/avito/core/retries.py b/avito/core/retries.py index 76c8d64..11e173d 100644 --- a/avito/core/retries.py +++ b/avito/core/retries.py @@ -4,8 +4,7 @@ from dataclasses import dataclass from pathlib import Path -from typing import ClassVar -from typing import Literal +from typing import ClassVar, Literal from avito._env import ( parse_env_bool, @@ -47,17 +46,42 @@ def from_env(cls, *, env_file: str | Path | None = ".env") -> RetryPolicy: """Загружает retry-политику из process environment и optional `.env` файла.""" resolved_values = resolve_env_aliases(cls.ENV_ALIASES, env_file=env_file) - parsed_values: dict[str, object] = {} + defaults = cls() + max_attempts = defaults.max_attempts + backoff_factor = defaults.backoff_factor + retryable_methods = defaults.retryable_methods + retry_on_rate_limit = defaults.retry_on_rate_limit + retry_on_server_error = defaults.retry_on_server_error + retry_on_transport_error = defaults.retry_on_transport_error + max_rate_limit_wait_seconds = defaults.max_rate_limit_wait_seconds for field_name, value in resolved_values.items(): if field_name == "max_attempts": - parsed_values[field_name] = parse_env_int(value, field_name=field_name) + max_attempts = parse_env_int(value, field_name=field_name) elif field_name in {"backoff_factor", "max_rate_limit_wait_seconds"}: - parsed_values[field_name] = parse_env_float(value, field_name=field_name) + parsed_float = parse_env_float(value, field_name=field_name) + if field_name == "backoff_factor": + backoff_factor = parsed_float + else: + max_rate_limit_wait_seconds = parsed_float elif field_name == "retryable_methods": - parsed_values[field_name] = parse_env_str_tuple(value, field_name=field_name) + retryable_methods = parse_env_str_tuple(value, field_name=field_name) else: - parsed_values[field_name] = parse_env_bool(value, field_name=field_name) - return cls(**parsed_values) + parsed_bool = parse_env_bool(value, field_name=field_name) + if field_name == "retry_on_rate_limit": + retry_on_rate_limit = parsed_bool + elif field_name == "retry_on_server_error": + retry_on_server_error = parsed_bool + else: + retry_on_transport_error = parsed_bool + return cls( + max_attempts=max_attempts, + backoff_factor=backoff_factor, + retryable_methods=retryable_methods, + retry_on_rate_limit=retry_on_rate_limit, + retry_on_server_error=retry_on_server_error, + retry_on_transport_error=retry_on_transport_error, + max_rate_limit_wait_seconds=max_rate_limit_wait_seconds, + ) def is_retryable_method(self, method: str, *, explicit_retry: bool = False) -> bool: """Определяет, можно ли повторять запрос указанного HTTP-метода.""" diff --git a/avito/core/transport.py b/avito/core/transport.py index 88bf0c9..c6a109e 100644 --- a/avito/core/transport.py +++ b/avito/core/transport.py @@ -52,6 +52,17 @@ RequestFiles = Mapping[str, FileValue] +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, + ) + + class Transport: """Выполняет HTTP-запросы, применяет retry и маппит ошибки API.""" @@ -68,7 +79,7 @@ def __init__( self._retry_policy = settings.retry_policy self._client = client or httpx.Client( base_url=settings.base_url.rstrip("/"), - timeout=self._build_timeout(settings.timeouts), + timeout=build_httpx_timeout(settings.timeouts), ) self._sleep = sleep @@ -109,7 +120,7 @@ def request( normalized_path = self._normalize_path(path) request_headers = self._merge_headers(context=context, headers=headers) - timeout = self._build_timeout(context.timeout or self._settings.timeouts) + timeout = build_httpx_timeout(context.timeout or self._settings.timeouts) attempt = 0 unauthorized_refresh_used = False @@ -502,13 +513,4 @@ def _extract_filename(self, content_disposition: str | None) -> str | None: return decoded_value return filename - def _build_timeout(self, timeouts: ApiTimeouts) -> httpx.Timeout: - return httpx.Timeout( - connect=timeouts.connect, - read=timeouts.read, - write=timeouts.write, - pool=timeouts.pool, - ) - - -__all__ = ("Transport",) +__all__ = ("Transport", "build_httpx_timeout") diff --git a/avito/core/types.py b/avito/core/types.py index d5a7a0b..58afb6f 100644 --- a/avito/core/types.py +++ b/avito/core/types.py @@ -5,8 +5,7 @@ from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path -from typing import ClassVar -from typing import Literal +from typing import ClassVar, Literal from avito._env import parse_env_float, resolve_env_aliases diff --git a/avito/cpa/domain.py b/avito/cpa/domain.py index a81bdeb..f01c643 100644 --- a/avito/cpa/domain.py +++ b/avito/cpa/domain.py @@ -95,11 +95,18 @@ class CpaCall(DomainObject): user_id: int | str | None = None - def list(self, *, request: CpaCallsByTimeRequest) -> CpaCallsResult: - return CpaCallsClient(self.transport).list_by_time(request) + def list(self, *, date_time_from: str, date_time_to: str) -> CpaCallsResult: + return CpaCallsClient(self.transport).list_by_time( + CpaCallsByTimeRequest( + date_time_from=date_time_from, + date_time_to=date_time_to, + ) + ) - def create_complaint(self, *, request: CpaCallComplaintRequest) -> CpaActionResult: - return CpaCallsClient(self.transport).create_complaint(request) + def create_complaint(self, *, call_id: int, reason: str) -> CpaActionResult: + return CpaCallsClient(self.transport).create_complaint( + CpaCallComplaintRequest(call_id=call_id, reason=reason) + ) @dataclass(slots=True, frozen=True) @@ -117,8 +124,10 @@ def get_call(self, *, call_id: int | str | None = None) -> CpaAudioRecord: def get_balance_info(self) -> CpaBalanceInfo: return CpaArchiveClient(self.transport).get_balance_info() - def get_call_by_id(self, *, request: CpaCallByIdRequest) -> CpaCallInfo: - return CpaArchiveClient(self.transport).get_call_by_id(request) + def get_call_by_id(self, *, call_id: int) -> CpaCallInfo: + return CpaArchiveClient(self.transport).get_call_by_id( + CpaCallByIdRequest(call_id=call_id) + ) def _require_call_id(self) -> str: if self.call_id is None: @@ -143,8 +152,22 @@ def get(self, *, call_id: int | None = None) -> CallTrackingCallResponse: CallTrackingGetCallByIdRequest(call_id=resolved_call_id) ) - def list(self, *, request: CallTrackingCallsRequest) -> CallTrackingCallsResult: - return CallTrackingClient(self.transport).get_calls(request) + def list( + self, + *, + date_time_from: str, + date_time_to: str, + limit: int | None = None, + offset: int | None = None, + ) -> CallTrackingCallsResult: + return CallTrackingClient(self.transport).get_calls( + CallTrackingCallsRequest( + date_time_from=date_time_from, + date_time_to=date_time_to, + limit=limit, + offset=offset, + ) + ) def download(self, *, call_id: int | str | None = None) -> CallTrackingRecord: return CallTrackingClient(self.transport).get_record_by_call_id( diff --git a/avito/jobs/domain.py b/avito/jobs/domain.py index 250d5b9..ee98fa0 100644 --- a/avito/jobs/domain.py +++ b/avito/jobs/domain.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass from avito.core import ValidationError @@ -20,6 +21,7 @@ ApplicationIdsResult, ApplicationsResult, ApplicationStatesResult, + ApplicationViewedItem, ApplicationViewedRequest, JobActionResult, JobDictionariesResult, @@ -51,8 +53,9 @@ class Vacancy(DomainObject): vacancy_id: int | str | None = None user_id: int | str | None = None - def create(self, *, request: VacancyCreateRequest, version: int = 2) -> JobActionResult: + def create(self, *, title: str, version: int = 2) -> JobActionResult: client = VacanciesClient(self.transport) + request = VacancyCreateRequest(title=title) if version == 1: return client.create_classic(request) return client.create(request) @@ -101,11 +104,11 @@ def get( query=query, ) - def get_by_ids(self, *, request: VacancyIdsRequest) -> VacanciesResult: - return VacanciesClient(self.transport).get_by_ids(request) + def get_by_ids(self, *, ids: Sequence[int]) -> VacanciesResult: + return VacanciesClient(self.transport).get_by_ids(VacancyIdsRequest(ids=list(ids))) - def get_statuses(self, *, request: VacancyIdsRequest) -> VacancyStatusesResult: - return VacanciesClient(self.transport).get_statuses(request) + def get_statuses(self, *, ids: Sequence[int]) -> VacancyStatusesResult: + return VacanciesClient(self.transport).get_statuses(VacancyIdsRequest(ids=list(ids))) def update_auto_renewal( self, *, request: VacancyAutoRenewalRequest, vacancy_uuid: str | None = None @@ -127,8 +130,10 @@ class Application(DomainObject): user_id: int | str | None = None - def apply(self, *, request: ApplicationActionRequest) -> JobActionResult: - return ApplicationsClient(self.transport).apply_actions(request) + def apply(self, *, ids: Sequence[str], action: str) -> JobActionResult: + return ApplicationsClient(self.transport).apply_actions( + ApplicationActionRequest(ids=list(ids), action=action) + ) def list( self, @@ -146,8 +151,10 @@ def list( def get_states(self) -> ApplicationStatesResult: return ApplicationsClient(self.transport).get_states() - def update(self, *, request: ApplicationViewedRequest) -> JobActionResult: - return ApplicationsClient(self.transport).set_is_viewed(request) + def update(self, *, applies: Sequence[ApplicationViewedItem]) -> JobActionResult: + return ApplicationsClient(self.transport).set_is_viewed( + ApplicationViewedRequest(applies=list(applies)) + ) @dataclass(slots=True, frozen=True) @@ -188,8 +195,8 @@ def get(self) -> JobWebhookInfo: def list(self) -> JobWebhooksResult: return WebhookClient(self.transport).list_webhooks() - def update(self, *, request: JobWebhookUpdateRequest) -> JobWebhookInfo: - return WebhookClient(self.transport).put_webhook(request) + def update(self, *, url: str) -> JobWebhookInfo: + return WebhookClient(self.transport).put_webhook(JobWebhookUpdateRequest(url=url)) def delete(self, *, url: str | None = None) -> JobActionResult: return WebhookClient(self.transport).delete_webhook(url=url) diff --git a/avito/orders/domain.py b/avito/orders/domain.py index cfec416..5f0e50c 100644 --- a/avito/orders/domain.py +++ b/avito/orders/domain.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass from avito.core import ValidationError @@ -19,17 +20,25 @@ AddTariffV2Request, AddTerminalsRequest, CancelParcelRequest, + CancelSandboxParcelOptions, CancelSandboxParcelRequest, + ChangeParcelApplication, + ChangeParcelOptions, ChangeParcelRequest, CourierRangesResult, + CustomAreaScheduleEntry, CustomAreaScheduleRequest, DeliveryAnnouncementRequest, + DeliveryDirection, DeliveryEntityResult, DeliveryParcelIdsRequest, DeliveryParcelRequest, DeliveryParcelResultRequest, DeliverySortingCentersResult, + DeliveryTariffZone, DeliveryTaskInfo, + DeliveryTermsZone, + DeliveryTrackingOptions, DeliveryTrackingRequest, GetChangeParcelInfoRequest, GetRegisteredParcelIdRequest, @@ -42,23 +51,34 @@ OrderCncDetailsRequest, OrderConfirmationCodeRequest, OrderCourierRangeRequest, + OrderDeliveryProperties, OrderLabelsRequest, OrderMarkingsRequest, OrdersResult, OrderTrackingNumberRequest, ProhibitOrderAcceptanceRequest, + RealAddress, + SandboxAnnouncementPackage, + SandboxAnnouncementParticipant, + SandboxArea, SandboxAreasRequest, + SandboxCancelAnnouncementOptions, SandboxCancelAnnouncementRequest, SandboxConfirmationCodeRequest, + SandboxCreateAnnouncementOptions, SandboxCreateAnnouncementRequest, SandboxGetAnnouncementEventRequest, SetOrderPropertiesRequest, SetOrderRealAddressRequest, + SortingCenterUpload, StockInfoRequest, StockInfoResult, + StockUpdateEntry, StockUpdateRequest, StockUpdateResult, + TaggedSortingCenter, TaggedSortingCentersRequest, + TerminalUpload, UpdateTermsRequest, ) @@ -72,31 +92,43 @@ class Order(DomainObject): def list(self) -> OrdersResult: return OrdersClient(self.transport).list_orders() - def update_markings(self, *, request: OrderMarkingsRequest) -> OrderActionResult: - return OrdersClient(self.transport).update_markings(request) + def update_markings(self, *, order_id: str, codes: Sequence[str]) -> OrderActionResult: + return OrdersClient(self.transport).update_markings( + OrderMarkingsRequest(order_id=order_id, codes=list(codes)) + ) - def accept_return_order(self, *, request: OrderAcceptReturnRequest) -> OrderActionResult: - return OrdersClient(self.transport).accept_return_order(request) + def accept_return_order(self, *, order_id: str, postal_office_id: str) -> OrderActionResult: + return OrdersClient(self.transport).accept_return_order( + OrderAcceptReturnRequest(order_id=order_id, postal_office_id=postal_office_id) + ) - def apply(self, *, request: OrderApplyTransitionRequest) -> OrderActionResult: - return OrdersClient(self.transport).apply_transition(request) + def apply(self, *, order_id: str, transition: str) -> OrderActionResult: + return OrdersClient(self.transport).apply_transition( + OrderApplyTransitionRequest(order_id=order_id, transition=transition) + ) - def check_confirmation_code( - self, *, request: OrderConfirmationCodeRequest - ) -> OrderActionResult: - return OrdersClient(self.transport).check_confirmation_code(request) + def check_confirmation_code(self, *, order_id: str, code: str) -> OrderActionResult: + return OrdersClient(self.transport).check_confirmation_code( + OrderConfirmationCodeRequest(order_id=order_id, code=code) + ) - def set_cnc_details(self, *, request: OrderCncDetailsRequest) -> OrderActionResult: - return OrdersClient(self.transport).set_cnc_details(request) + def set_cnc_details(self, *, order_id: str, pickup_point_id: str) -> OrderActionResult: + return OrdersClient(self.transport).set_cnc_details( + OrderCncDetailsRequest(order_id=order_id, pickup_point_id=pickup_point_id) + ) def get_courier_delivery_range(self) -> CourierRangesResult: return OrdersClient(self.transport).get_courier_delivery_range() - def set_courier_delivery_range(self, *, request: OrderCourierRangeRequest) -> OrderActionResult: - return OrdersClient(self.transport).set_courier_delivery_range(request) + def set_courier_delivery_range(self, *, order_id: str, interval_id: str) -> OrderActionResult: + return OrdersClient(self.transport).set_courier_delivery_range( + OrderCourierRangeRequest(order_id=order_id, interval_id=interval_id) + ) - def update_tracking_number(self, *, request: OrderTrackingNumberRequest) -> OrderActionResult: - return OrdersClient(self.transport).set_tracking_number(request) + def update_tracking_number(self, *, order_id: str, tracking_number: str) -> OrderActionResult: + return OrdersClient(self.transport).set_tracking_number( + OrderTrackingNumberRequest(order_id=order_id, tracking_number=tracking_number) + ) @dataclass(slots=True, frozen=True) @@ -106,8 +138,9 @@ class OrderLabel(DomainObject): task_id: int | str | None = None user_id: int | str | None = None - def create(self, *, request: OrderLabelsRequest, extended: bool = False) -> LabelTaskResult: + def create(self, *, order_ids: Sequence[str], extended: bool = False) -> LabelTaskResult: client = LabelsClient(self.transport) + request = OrderLabelsRequest(order_ids=list(order_ids)) if extended: return client.create_generate_labels_extended(request) return client.create_generate_labels(request) @@ -128,22 +161,30 @@ class DeliveryOrder(DomainObject): user_id: int | str | None = None - def create_announcement(self, *, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: - return DeliveryClient(self.transport).create_announcement(request) + def create_announcement(self, *, order_id: str) -> DeliveryEntityResult: + return DeliveryClient(self.transport).create_announcement( + DeliveryAnnouncementRequest(order_id=order_id) + ) - def delete(self, *, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: - return DeliveryClient(self.transport).cancel_announcement(request) + def delete(self, *, order_id: str) -> DeliveryEntityResult: + return DeliveryClient(self.transport).cancel_announcement( + DeliveryAnnouncementRequest(order_id=order_id) + ) - def create(self, *, request: DeliveryParcelRequest) -> DeliveryEntityResult: - return DeliveryClient(self.transport).create_parcel(request) + def create(self, *, order_id: str, parcel_id: str) -> DeliveryEntityResult: + return DeliveryClient(self.transport).create_parcel( + DeliveryParcelRequest(order_id=order_id, parcel_id=parcel_id) + ) - def update_change_parcels(self, *, request: DeliveryParcelIdsRequest) -> DeliveryEntityResult: - return DeliveryClient(self.transport).update_change_parcels(request) + def update_change_parcels(self, *, parcel_ids: Sequence[str]) -> DeliveryEntityResult: + return DeliveryClient(self.transport).update_change_parcels( + DeliveryParcelIdsRequest(parcel_ids=list(parcel_ids)) + ) - def create_change_parcel_result( - self, *, request: DeliveryParcelResultRequest - ) -> DeliveryEntityResult: - return DeliveryClient(self.transport).change_parcel_result(request) + def create_change_parcel_result(self, *, parcel_id: str, result: str) -> DeliveryEntityResult: + return DeliveryClient(self.transport).change_parcel_result( + DeliveryParcelResultRequest(parcel_id=parcel_id, result=result) + ) @dataclass(slots=True, frozen=True) @@ -152,111 +193,223 @@ class SandboxDelivery(DomainObject): user_id: int | str | None = None - def create_announcement(self, *, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).create_announcement(request) + def create_announcement(self, *, order_id: str) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).create_announcement( + DeliveryAnnouncementRequest(order_id=order_id) + ) - def track_announcement(self, *, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).track_announcement(request) + def track_announcement(self, *, order_id: str) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).track_announcement( + DeliveryAnnouncementRequest(order_id=order_id) + ) def update_custom_area_schedule( - self, *, request: CustomAreaScheduleRequest + self, *, items: Sequence[CustomAreaScheduleEntry] ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).update_custom_area_schedule(request) - - def cancel_parcel(self, *, request: CancelParcelRequest) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).cancel_parcel(request) + return SandboxDeliveryClient(self.transport).update_custom_area_schedule( + CustomAreaScheduleRequest(items=list(items)) + ) - def check_confirmation_code( - self, *, request: SandboxConfirmationCodeRequest - ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).check_confirmation_code(request) + def cancel_parcel(self, *, parcel_id: str, actor: str) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).cancel_parcel( + CancelParcelRequest(parcel_id=parcel_id, actor=actor) + ) - def set_order_properties(self, *, request: SetOrderPropertiesRequest) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).set_order_properties(request) + def check_confirmation_code(self, *, parcel_id: str, confirm_code: str) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).check_confirmation_code( + SandboxConfirmationCodeRequest(parcel_id=parcel_id, confirm_code=confirm_code) + ) - def set_order_real_address( - self, *, request: SetOrderRealAddressRequest + def set_order_properties( + self, *, order_id: str, properties: OrderDeliveryProperties ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).set_order_real_address(request) + return SandboxDeliveryClient(self.transport).set_order_properties( + SetOrderPropertiesRequest(order_id=order_id, properties=properties) + ) - def tracking(self, *, request: DeliveryTrackingRequest) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).tracking(request) + def set_order_real_address(self, *, order_id: str, address: RealAddress) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).set_order_real_address( + SetOrderRealAddressRequest(order_id=order_id, address=address) + ) - def prohibit_order_acceptance( - self, *, request: ProhibitOrderAcceptanceRequest + def tracking( + self, + *, + order_id: str, + avito_status: str, + avito_event_type: str, + provider_event_code: str, + date: str, + location: str, + comment: str | None = None, + options: DeliveryTrackingOptions | None = None, ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).prohibit_order_acceptance(request) + return SandboxDeliveryClient(self.transport).tracking( + DeliveryTrackingRequest( + order_id=order_id, + avito_status=avito_status, + avito_event_type=avito_event_type, + provider_event_code=provider_event_code, + date=date, + location=location, + comment=comment, + options=options, + ) + ) + + def prohibit_order_acceptance(self, *, order_id: str) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).prohibit_order_acceptance( + ProhibitOrderAcceptanceRequest(order_id=order_id) + ) def list_sorting_center(self) -> DeliverySortingCentersResult: return SandboxDeliveryClient(self.transport).list_sorting_center() - def add_sorting_center(self, *, request: AddSortingCentersRequest) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).add_sorting_center(request) + def add_sorting_center(self, *, items: Sequence[SortingCenterUpload]) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).add_sorting_center( + AddSortingCentersRequest(items=list(items)) + ) - def add_areas(self, *, tariff_id: str, request: SandboxAreasRequest) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).add_areas(tariff_id=tariff_id, request=request) + def add_areas(self, *, tariff_id: str, areas: Sequence[SandboxArea]) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).add_areas( + tariff_id=tariff_id, + request=SandboxAreasRequest(areas=list(areas)), + ) def add_tags_to_sorting_center( - self, *, tariff_id: str, request: TaggedSortingCentersRequest + self, *, tariff_id: str, items: Sequence[TaggedSortingCenter] ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).add_tags_to_sorting_center( tariff_id=tariff_id, - request=request, + request=TaggedSortingCentersRequest(items=list(items)), ) def add_terminals( - self, *, tariff_id: str, request: AddTerminalsRequest + self, *, tariff_id: str, items: Sequence[TerminalUpload] ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).add_terminals( - tariff_id=tariff_id, request=request + tariff_id=tariff_id, + request=AddTerminalsRequest(items=list(items)), ) - def update_terms(self, *, tariff_id: str, request: UpdateTermsRequest) -> DeliveryEntityResult: + def update_terms(self, *, tariff_id: str, items: Sequence[DeliveryTermsZone]) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).update_terms( - tariff_id=tariff_id, request=request + tariff_id=tariff_id, + request=UpdateTermsRequest(items=list(items)), ) - def add_tariff(self, *, request: AddTariffV2Request) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).add_tariff(request) + 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, + ) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).add_tariff( + 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, + ) + ) - def create_parcel(self, *, request: DeliveryParcelRequest) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).create_parcel(request) + def create_parcel(self, *, order_id: str, parcel_id: str) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).create_parcel( + DeliveryParcelRequest(order_id=order_id, parcel_id=parcel_id) + ) def cancel_sandbox_announcement( - self, *, request: SandboxCancelAnnouncementRequest + self, + *, + announcement_id: str, + date: str, + options: SandboxCancelAnnouncementOptions, ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).cancel_sandbox_announcement(request) + return SandboxDeliveryClient(self.transport).cancel_sandbox_announcement( + SandboxCancelAnnouncementRequest( + announcement_id=announcement_id, + date=date, + options=options, + ) + ) - def cancel_sandbox_parcel(self, *, request: CancelSandboxParcelRequest) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).cancel_sandbox_parcel(request) + def cancel_sandbox_parcel( + self, + *, + parcel_id: str, + options: CancelSandboxParcelOptions | None = None, + ) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).cancel_sandbox_parcel( + CancelSandboxParcelRequest(parcel_id=parcel_id, options=options) + ) - def change_sandbox_parcel(self, *, request: ChangeParcelRequest) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).change_sandbox_parcel(request) + def change_sandbox_parcel( + self, + *, + type: str, + parcel_id: str, + application: ChangeParcelApplication | None = None, + options: ChangeParcelOptions | None = None, + ) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).change_sandbox_parcel( + ChangeParcelRequest( + type=type, + parcel_id=parcel_id, + application=application, + options=options, + ) + ) def create_sandbox_announcement( - self, *, request: SandboxCreateAnnouncementRequest + self, + *, + announcement_id: str, + barcode: str, + sender: SandboxAnnouncementParticipant, + receiver: SandboxAnnouncementParticipant, + announcement_type: str, + date: str, + packages: Sequence[SandboxAnnouncementPackage], + options: SandboxCreateAnnouncementOptions, ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).create_sandbox_announcement(request) + return SandboxDeliveryClient(self.transport).create_sandbox_announcement( + SandboxCreateAnnouncementRequest( + announcement_id=announcement_id, + barcode=barcode, + sender=sender, + receiver=receiver, + announcement_type=announcement_type, + date=date, + packages=list(packages), + options=options, + ) + ) - def get_sandbox_announcement_event( - self, *, request: SandboxGetAnnouncementEventRequest - ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).get_sandbox_announcement_event(request) + def get_sandbox_announcement_event(self, *, announcement_id: str) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).get_sandbox_announcement_event( + SandboxGetAnnouncementEventRequest(announcement_id=announcement_id) + ) - def get_sandbox_change_parcel_info( - self, *, request: GetChangeParcelInfoRequest - ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).get_sandbox_change_parcel_info(request) + def get_sandbox_change_parcel_info(self, *, application_id: str) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).get_sandbox_change_parcel_info( + GetChangeParcelInfoRequest(application_id=application_id) + ) - def get_sandbox_parcel_info( - self, *, request: GetSandboxParcelInfoRequest - ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).get_sandbox_parcel_info(request) + def get_sandbox_parcel_info(self, *, parcel_id: str) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).get_sandbox_parcel_info( + GetSandboxParcelInfoRequest(parcel_id=parcel_id) + ) - def get_sandbox_registered_parcel_id( - self, *, request: GetRegisteredParcelIdRequest - ) -> DeliveryEntityResult: - return SandboxDeliveryClient(self.transport).get_sandbox_registered_parcel_id(request) + def get_sandbox_registered_parcel_id(self, *, order_id: str) -> DeliveryEntityResult: + return SandboxDeliveryClient(self.transport).get_sandbox_registered_parcel_id( + GetRegisteredParcelIdRequest(order_id=order_id) + ) @dataclass(slots=True, frozen=True) @@ -282,11 +435,15 @@ class Stock(DomainObject): user_id: int | str | None = None - def get(self, *, request: StockInfoRequest) -> StockInfoResult: - return StockManagementClient(self.transport).get_info(request) + def get(self, *, item_ids: Sequence[int]) -> StockInfoResult: + return StockManagementClient(self.transport).get_info( + StockInfoRequest(item_ids=list(item_ids)) + ) - def update(self, *, request: StockUpdateRequest) -> StockUpdateResult: - return StockManagementClient(self.transport).update_stocks(request) + def update(self, *, stocks: Sequence[StockUpdateEntry]) -> StockUpdateResult: + return StockManagementClient(self.transport).update_stocks( + StockUpdateRequest(stocks=list(stocks)) + ) __all__ = ( diff --git a/avito/promotion/__init__.py b/avito/promotion/__init__.py index e820d2e..059d0db 100644 --- a/avito/promotion/__init__.py +++ b/avito/promotion/__init__.py @@ -15,7 +15,6 @@ AutostrategyStatTotals, BbipBudgetOption, BbipDurationRange, - BbipForecast, BbipForecastsResult, BbipItem, BbipSuggest, @@ -33,6 +32,7 @@ CpaAuctionBidsResult, CreateItemBid, PromotionActionResult, + PromotionForecast, PromotionOrderError, PromotionOrderInfo, PromotionOrdersResult, @@ -63,7 +63,6 @@ "AutostrategyStatTotals", "BbipBudgetOption", "BbipDurationRange", - "BbipForecast", "BbipForecastsResult", "BbipItem", "BbipPromotion", @@ -83,6 +82,7 @@ "CpaAuctionBidsResult", "CreateItemBid", "PromotionActionResult", + "PromotionForecast", "PromotionOrder", "PromotionOrderError", "PromotionOrderInfo", diff --git a/avito/promotion/domain.py b/avito/promotion/domain.py index 0a5c4d9..ec5868f 100644 --- a/avito/promotion/domain.py +++ b/avito/promotion/domain.py @@ -86,6 +86,11 @@ def _preview_result( ) +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 PromotionOrder(DomainObject): """Доменный объект заявок и словарей promotion API.""" @@ -414,12 +419,14 @@ def create_budget( self, *, campaign_type: str, - start_time: str | None = None, - finish_time: str | None = None, + start_time: datetime | None = None, + finish_time: datetime | None = None, items: list[int] | None = None, ) -> AutostrategyBudget: """Рассчитывает бюджет кампании.""" + _validate_optional_datetime("start_time", start_time) + _validate_optional_datetime("finish_time", finish_time) return AutostrategyClient(self.transport).create_budget( CreateAutostrategyBudgetRequest( campaign_type=campaign_type, @@ -439,12 +446,14 @@ def create( budget_real: int | None = None, calc_id: int | None = None, description: str | None = None, - finish_time: str | None = None, + finish_time: datetime | None = None, items: list[int] | None = None, - start_time: str | None = None, + start_time: datetime | None = None, ) -> CampaignActionResult: """Создает новую кампанию.""" + _validate_optional_datetime("start_time", start_time) + _validate_optional_datetime("finish_time", finish_time) return AutostrategyClient(self.transport).create_campaign( CreateAutostrategyCampaignRequest( campaign_type=campaign_type, @@ -468,13 +477,15 @@ def update( budget: int | None = None, calc_id: int | None = None, description: str | None = None, - finish_time: str | None = None, + finish_time: datetime | None = None, items: list[int] | None = None, - start_time: str | None = None, + start_time: datetime | None = None, title: str | None = None, ) -> CampaignActionResult: """Редактирует кампанию.""" + _validate_optional_datetime("start_time", start_time) + _validate_optional_datetime("finish_time", finish_time) return AutostrategyClient(self.transport).edit_campaign( UpdateAutostrategyCampaignRequest( campaign_id=campaign_id or self._require_campaign_id(), diff --git a/avito/promotion/mappers.py b/avito/promotion/mappers.py index 574a58b..41a2728 100644 --- a/avito/promotion/mappers.py +++ b/avito/promotion/mappers.py @@ -16,7 +16,6 @@ AutostrategyStatTotals, BbipBudgetOption, BbipDurationRange, - BbipForecast, BbipForecastsResult, BbipSuggest, BbipSuggestsResult, @@ -32,6 +31,7 @@ CpaAuctionItemBid, PromotionActionItem, PromotionActionResult, + PromotionForecast, PromotionOrderError, PromotionOrderInfo, PromotionOrdersResult, @@ -218,7 +218,7 @@ def map_bbip_forecasts(payload: object) -> BbipForecastsResult: data = _expect_mapping(payload) return BbipForecastsResult( items=[ - BbipForecast( + PromotionForecast( item_id=_int(item, "itemId", "itemID"), min_views=_int(item, "min"), max_views=_int(item, "max"), diff --git a/avito/promotion/models.py b/avito/promotion/models.py index 127b6f8..757fc6c 100644 --- a/avito/promotion/models.py +++ b/avito/promotion/models.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from datetime import datetime -from typing import TypeAlias, TypedDict +from typing import TypedDict from avito.core.serialization import SerializableModel @@ -196,7 +196,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class BbipForecast(SerializableModel): +class PromotionForecast(SerializableModel): """Прогноз BBIP по объявлению.""" item_id: int | None @@ -210,11 +210,7 @@ class BbipForecast(SerializableModel): class BbipForecastsResult(SerializableModel): """Результат прогноза BBIP.""" - items: list[BbipForecast] - - -# deprecated: используйте BbipForecast напрямую -PromotionForecast: TypeAlias = BbipForecast + items: list[PromotionForecast] @dataclass(slots=True, frozen=True) diff --git a/avito/realty/domain.py b/avito/realty/domain.py index 2487acd..3604936 100644 --- a/avito/realty/domain.py +++ b/avito/realty/domain.py @@ -14,6 +14,7 @@ RealtyBookingsQuery, RealtyBookingsResult, RealtyBookingsUpdateRequest, + RealtyInterval, RealtyIntervalsRequest, RealtyMarketPriceInfo, RealtyPricesUpdateRequest, @@ -27,8 +28,18 @@ class RealtyListing(DomainObject): item_id: int | str | None = None user_id: int | str | None = None - def get_intervals(self, *, request: RealtyIntervalsRequest) -> RealtyActionResult: - return ShortTermRentClient(self.transport).get_intervals(request) + def get_intervals( + self, + *, + intervals: list[RealtyInterval], + item_id: int | None = None, + ) -> RealtyActionResult: + return ShortTermRentClient(self.transport).get_intervals( + RealtyIntervalsRequest( + item_id=item_id or int(self._require_item_id()), + intervals=intervals, + ) + ) def update_base_params( self, *, request: RealtyBaseParamsUpdateRequest, item_id: int | str | None = None diff --git a/avito/settings.py b/avito/settings.py index c6f2fb2..84bfe25 100644 --- a/avito/settings.py +++ b/avito/settings.py @@ -2,14 +2,14 @@ import warnings +from avito.config import AvitoSettings +from avito.core.retries import RetryPolicy +from avito.core.types import ApiTimeouts + warnings.warn( "avito.settings устарел и будет удалён. Используйте `avito.config.AvitoSettings` напрямую.", DeprecationWarning, stacklevel=2, ) -from avito.config import AvitoSettings -from avito.core.retries import RetryPolicy -from avito.core.types import ApiTimeouts - __all__ = ("ApiTimeouts", "AvitoSettings", "RetryPolicy") diff --git a/tests/contracts/test_client_contracts.py b/tests/contracts/test_client_contracts.py index 92cd25e..36431d0 100644 --- a/tests/contracts/test_client_contracts.py +++ b/tests/contracts/test_client_contracts.py @@ -14,6 +14,8 @@ AutotekaValuation, AutotekaVehicle, ) +from avito.core import Transport +from avito.core.types import ApiTimeouts from avito.cpa import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead from avito.jobs import Application, JobDictionary, JobWebhook, Resume, Vacancy from avito.messenger import Chat, ChatMedia, ChatMessage, ChatWebhook, SpecialOfferCampaign @@ -26,10 +28,9 @@ TargetActionPricing, TrxPromotion, ) -from avito.realty import RealtyAnalyticsReport, RealtyBooking, RealtyListing, RealtyPricing from avito.ratings import RatingProfile, Review, ReviewAnswer +from avito.realty import RealtyAnalyticsReport, RealtyBooking, RealtyListing, RealtyPricing from avito.tariffs import Tariff -from avito.core import Transport def test_single_client_exposes_domain_factories() -> None: @@ -135,3 +136,31 @@ def test_debug_info_and_context_manager_do_not_leak_secrets() -> None: assert token_http_client.is_closed is True assert alternate_http_client.is_closed is True assert autoteka_http_client.is_closed is True + + +def test_auth_token_clients_use_explicit_sdk_timeouts() -> None: + settings = AvitoSettings( + base_url="https://api.avito.ru", + timeouts=ApiTimeouts(connect=2.5, read=11.0, write=13.0, pool=3.0), + auth=AuthSettings(client_id="client-id", client_secret="super-secret"), + ) + + client = AvitoClient(settings) + token_timeout = client.auth_provider.token_flow().client.timeout + alternate_timeout = client.auth_provider.alternate_token_flow().client.timeout + autoteka_timeout = client.auth_provider.autoteka_token_client.client.timeout + + assert token_timeout.connect == 2.5 + assert token_timeout.read == 11.0 + assert token_timeout.write == 13.0 + assert token_timeout.pool == 3.0 + assert alternate_timeout.connect == 2.5 + assert alternate_timeout.read == 11.0 + assert alternate_timeout.write == 13.0 + assert alternate_timeout.pool == 3.0 + assert autoteka_timeout.connect == 2.5 + assert autoteka_timeout.read == 11.0 + assert autoteka_timeout.write == 13.0 + assert autoteka_timeout.pool == 3.0 + + client.close() diff --git a/tests/contracts/test_model_contracts.py b/tests/contracts/test_model_contracts.py index ed9bf8b..b7159bc 100644 --- a/tests/contracts/test_model_contracts.py +++ b/tests/contracts/test_model_contracts.py @@ -34,8 +34,8 @@ CampaignActionResult, CampaignListFilter, CampaignOrderBy, - CampaignUpdateTimeFilter, CampaignsResult, + CampaignUpdateTimeFilter, CreateAutostrategyBudgetRequest, ListAutostrategyCampaignsRequest, PromotionForecast, diff --git a/tests/domains/autoteka/test_autoteka.py b/tests/domains/autoteka/test_autoteka.py index a1ef8ef..fe9ef47 100644 --- a/tests/domains/autoteka/test_autoteka.py +++ b/tests/domains/autoteka/test_autoteka.py @@ -4,21 +4,15 @@ import httpx -from avito.autoteka import AutotekaMonitoring, AutotekaReport, AutotekaScoring, AutotekaValuation, AutotekaVehicle +from avito.autoteka import ( + AutotekaMonitoring, + AutotekaReport, + AutotekaScoring, + AutotekaValuation, + AutotekaVehicle, +) from avito.autoteka.models import ( - CatalogResolveRequest, - ExternalItemPreviewRequest, - ItemIdRequest, - LeadsRequest, - MonitoringBucketRequest, MonitoringEventsQuery, - PlateNumberRequest, - PreviewReportRequest, - RegNumberRequest, - TeaserCreateRequest, - ValuationBySpecificationRequest, - VehicleIdRequest, - VinRequest, ) from tests.helpers.transport import make_transport @@ -54,17 +48,17 @@ def handler(request: httpx.Request) -> httpx.Response: vehicle = AutotekaVehicle(make_transport(httpx.MockTransport(handler)), vehicle_id="77") - assert vehicle.resolve_catalog(request=CatalogResolveRequest(brand_id=1)).items[0].values[0].label == "Audi" - assert vehicle.get_leads(request=LeadsRequest(limit=1)).last_id == 321 - assert vehicle.create_preview_by_vin(request=VinRequest(vin="VIN-1")).preview_id == "77" - assert vehicle.create_preview_by_item_id(request=ItemIdRequest(item_id=901)).preview_id == "78" - assert vehicle.create_preview_by_reg_number(request=RegNumberRequest(reg_number="A123AA77")).preview_id == "79" - assert vehicle.create_preview_by_external_item(request=ExternalItemPreviewRequest(item_id="ext-1", site="cars.example")).preview_id == "80" + assert vehicle.resolve_catalog(brand_id=1).items[0].values[0].label == "Audi" + assert vehicle.get_leads(limit=1).last_id == 321 + assert vehicle.create_preview_by_vin(vin="VIN-1").preview_id == "77" + assert vehicle.create_preview_by_item_id(item_id=901).preview_id == "78" + assert vehicle.create_preview_by_reg_number(reg_number="A123AA77").preview_id == "79" + assert vehicle.create_preview_by_external_item(item_id="ext-1", site="cars.example").preview_id == "80" assert vehicle.get_preview().vehicle_id == "VIN-1" - assert vehicle.create_specification_by_plate_number(request=PlateNumberRequest(plate_number="A123AA77")).specification_id == "501" - assert vehicle.create_specification_by_vehicle_id(request=VehicleIdRequest(vehicle_id="VIN-1")).specification_id == "502" + assert vehicle.create_specification_by_plate_number(plate_number="A123AA77").specification_id == "501" + assert vehicle.create_specification_by_vehicle_id(vehicle_id="VIN-1").specification_id == "502" assert vehicle.get_specification_by_id(specification_id="501").status == "success" - assert vehicle.create_teaser(request=TeaserCreateRequest(vehicle_id="VIN-1")).teaser_id == "601" + assert vehicle.create_teaser(vehicle_id="VIN-1").teaser_id == "601" assert vehicle.get_teaser(teaser_id="601").brand == "Audi" @@ -106,16 +100,16 @@ def handler(request: httpx.Request) -> httpx.Response: valuation = AutotekaValuation(transport) assert report.get_active_package().reports_remaining == 77 - assert report.create_report(request=PreviewReportRequest(preview_id=77)).report_id == "701" - assert report.create_report_by_vehicle_id(request=VehicleIdRequest(vehicle_id="VIN-1")).report_id == "702" + assert report.create_report(preview_id=77).report_id == "701" + assert report.create_report_by_vehicle_id(vehicle_id="VIN-1").report_id == "702" assert report.list_reports().items[0].vehicle_id == "VIN-1" assert report.get_report().web_link == "https://autoteka/web/701" - assert report.create_sync_report_by_reg_number(request=RegNumberRequest(reg_number="A123AA77")).status == "success" - assert report.create_sync_report_by_vin(request=VinRequest(vin="VIN-1")).report_id == "704" - assert monitoring.create_monitoring_bucket_add(request=MonitoringBucketRequest(vehicles=["VIN-1", "bad-vin"])).invalid_vehicles[0].vehicle_id == "bad-vin" + assert report.create_sync_report_by_reg_number(reg_number="A123AA77").status == "success" + assert report.create_sync_report_by_vin(vin="VIN-1").report_id == "704" + assert monitoring.create_monitoring_bucket_add(vehicles=["VIN-1", "bad-vin"]).invalid_vehicles[0].vehicle_id == "bad-vin" assert monitoring.delete_bucket().success is True - assert monitoring.remove_bucket(request=MonitoringBucketRequest(vehicles=["VIN-1"])).success is True + assert monitoring.remove_bucket(vehicles=["VIN-1"]).success is True assert monitoring.get_monitoring_reg_actions(query=MonitoringEventsQuery(limit=10)).items[0].operation_code == 11 - assert scoring.create_scoring_by_vehicle_id(request=VehicleIdRequest(vehicle_id="VIN-1")).scoring_id == "801" + assert scoring.create_scoring_by_vehicle_id(vehicle_id="VIN-1").scoring_id == "801" assert scoring.get_scoring_by_id().is_completed is True - assert valuation.get_valuation_by_specification(request=ValuationBySpecificationRequest(specification_id=501, mileage=30000)).avg_price_with_condition == 2100000 + assert valuation.get_valuation_by_specification(specification_id=501, mileage=30000).avg_price_with_condition == 2100000 diff --git a/tests/domains/cpa/test_cpa.py b/tests/domains/cpa/test_cpa.py index 34a5683..2244da1 100644 --- a/tests/domains/cpa/test_cpa.py +++ b/tests/domains/cpa/test_cpa.py @@ -6,10 +6,6 @@ from avito.cpa import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead from avito.cpa.models import ( - CallTrackingCallsRequest, - CpaCallByIdRequest, - CpaCallComplaintRequest, - CpaCallsByTimeRequest, CpaChatsByTimeRequest, CpaLeadComplaintRequest, CpaPhonesFromChatsRequest, @@ -61,12 +57,12 @@ def handler(request: httpx.Request) -> httpx.Response: cpa_lead = CpaLead(transport) archive = CpaArchive(transport, call_id="2001") - assert cpa_call.list(request=CpaCallsByTimeRequest(date_time_from="2026-04-18T00:00:00+03:00", date_time_to="2026-04-18T23:59:59+03:00")).items[0].record_url == "https://example.com/record-2001.mp3" - assert cpa_call.create_complaint(request=CpaCallComplaintRequest(call_id=2001, reason="spam")).success is True + assert cpa_call.list(date_time_from="2026-04-18T00:00:00+03:00", date_time_to="2026-04-18T23:59:59+03:00").items[0].record_url == "https://example.com/record-2001.mp3" + assert cpa_call.create_complaint(call_id=2001, reason="spam").success is True assert cpa_lead.create_complaint_by_action_id(request=CpaLeadComplaintRequest(action_id="act-1", reason="duplicate")).success is True assert cpa_lead.get_balance_info().balance == -5000 assert archive.get_balance_info().advance == 1000 - assert archive.get_call_by_id(request=CpaCallByIdRequest(call_id=2001)).call_id == "2001" + assert archive.get_call_by_id(call_id=2001).call_id == "2001" assert archive.get_call().binary.content == audio_bytes @@ -82,5 +78,5 @@ def handler(request: httpx.Request) -> httpx.Response: call = CallTrackingCall(make_transport(httpx.MockTransport(handler)), call_id="7001") assert call.get().call.call_id == "7001" - assert call.list(request=CallTrackingCallsRequest(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 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 call.download().binary.content == audio_bytes diff --git a/tests/domains/jobs/test_jobs.py b/tests/domains/jobs/test_jobs.py index 03549c3..e1c22a4 100644 --- a/tests/domains/jobs/test_jobs.py +++ b/tests/domains/jobs/test_jobs.py @@ -1,22 +1,15 @@ from __future__ import annotations -import json - import httpx from avito.jobs import Application, JobDictionary, JobWebhook, Resume, Vacancy from avito.jobs.models import ( - ApplicationActionRequest, ApplicationIdsQuery, ApplicationIdsRequest, ApplicationViewedItem, - ApplicationViewedRequest, - JobWebhookUpdateRequest, ResumeSearchQuery, VacancyArchiveRequest, VacancyAutoRenewalRequest, - VacancyCreateRequest, - VacancyIdsRequest, VacancyProlongateRequest, VacancyUpdateRequest, ) @@ -58,10 +51,10 @@ def handler(request: httpx.Request) -> httpx.Response: assert application.list(query=ApplicationIdsQuery(updated_at_from="2026-04-18")).items[0].id == "app-1" assert application.list(request=ApplicationIdsRequest(ids=["app-1"])).items[0].applicant_name == "Иван" assert application.get_states().items[0].slug == "new" - assert application.update(request=ApplicationViewedRequest(applies=[ApplicationViewedItem(id="app-1", is_viewed=True)])).status == "viewed" - assert application.apply(request=ApplicationActionRequest(ids=["app-1"], action="invited")).status == "invited" + assert application.update(applies=[ApplicationViewedItem(id="app-1", is_viewed=True)]).status == "viewed" + assert application.apply(ids=["app-1"], action="invited").status == "invited" assert webhook.get().url == "https://example.com/job" - assert webhook.update(request=JobWebhookUpdateRequest(url="https://example.com/job")).is_active is True + assert webhook.update(url="https://example.com/job").is_active is True assert webhook.delete(url="https://example.com/job").success is True assert webhook.list().items[0].version == "v1" assert resume.list(query=ResumeSearchQuery(query="оператор")).items[0].candidate_name == "Петр" @@ -102,14 +95,14 @@ def handler(request: httpx.Request) -> httpx.Response: vacancy = Vacancy(transport, vacancy_id="101") dictionary = JobDictionary(transport, dictionary_id="profession") - assert vacancy.create(request=VacancyCreateRequest(title="Продавец"), version=1).id == "101" + assert vacancy.create(title="Продавец", version=1).id == "101" assert vacancy.update(request=VacancyUpdateRequest(title="Старший продавец"), version=1).status == "updated" assert vacancy.delete(request=VacancyArchiveRequest(employee_id=7)).status == "archived" assert vacancy.prolongate(request=VacancyProlongateRequest(billing_type="package")).status == "prolongated" assert vacancy.list().items[0].uuid == "vac-uuid-1" - assert vacancy.create(request=VacancyCreateRequest(title="Вакансия v2")).id == "vac-uuid-1" - assert vacancy.get_by_ids(request=VacancyIdsRequest(ids=[101])).items[0].title == "Продавец" - assert vacancy.get_statuses(request=VacancyIdsRequest(ids=[101])).items[0].status == "active" + assert vacancy.create(title="Вакансия v2").id == "vac-uuid-1" + assert vacancy.get_by_ids(ids=[101]).items[0].title == "Продавец" + assert vacancy.get_statuses(ids=[101]).items[0].status == "active" assert vacancy.update(request=VacancyUpdateRequest(title="Вакансия v2 updated"), version=2, vacancy_uuid="vac-uuid-1").status == "updated" assert vacancy.get().url == "https://avito.ru/vacancy/101" assert vacancy.update_auto_renewal(request=VacancyAutoRenewalRequest(auto_renewal=True), vacancy_uuid="vac-uuid-1").status == "auto-renewal-updated" diff --git a/tests/domains/orders/test_orders.py b/tests/domains/orders/test_orders.py index 6a7ddc1..8636379 100644 --- a/tests/domains/orders/test_orders.py +++ b/tests/domains/orders/test_orders.py @@ -4,22 +4,9 @@ import httpx -from avito.orders import DeliveryOrder, DeliveryTask, Order, OrderLabel, SandboxDelivery, Stock +from avito.orders import DeliveryOrder, DeliveryTask, Order, OrderLabel, Stock from avito.orders.models import ( - DeliveryAnnouncementRequest, - DeliveryParcelRequest, - DeliveryParcelResultRequest, - DeliveryParcelIdsRequest, - OrderAcceptReturnRequest, - OrderApplyTransitionRequest, - OrderConfirmationCodeRequest, - OrderCourierRangeRequest, - OrderLabelsRequest, - OrderMarkingsRequest, - OrderTrackingNumberRequest, - StockInfoRequest, StockUpdateEntry, - StockUpdateRequest, ) from tests.helpers.transport import make_transport @@ -47,13 +34,13 @@ def handler(request: httpx.Request) -> httpx.Response: order = Order(make_transport(httpx.MockTransport(handler))) assert order.list().items[0].buyer_name == "Иван" - assert order.update_markings(request=OrderMarkingsRequest(order_id="ord-1", codes=["abc"])).status == "marked" - assert order.apply(request=OrderApplyTransitionRequest(order_id="ord-1", transition="confirm")).status == "confirmed" - assert order.check_confirmation_code(request=OrderConfirmationCodeRequest(order_id="ord-1", code="1234")).status == "code-valid" + assert order.update_markings(order_id="ord-1", codes=["abc"]).status == "marked" + assert order.apply(order_id="ord-1", transition="confirm").status == "confirmed" + assert order.check_confirmation_code(order_id="ord-1", code="1234").status == "code-valid" assert order.get_courier_delivery_range().items[0].interval_id == "int-1" - assert order.set_courier_delivery_range(request=OrderCourierRangeRequest(order_id="ord-1", interval_id="int-1")).status == "range-set" - assert order.update_tracking_number(request=OrderTrackingNumberRequest(order_id="ord-1", tracking_number="TRK-1")).status == "tracking-set" - assert order.accept_return_order(request=OrderAcceptReturnRequest(order_id="ord-1", postal_office_id="ops-1")).status == "return-accepted" + assert order.set_courier_delivery_range(order_id="ord-1", interval_id="int-1").status == "range-set" + assert order.update_tracking_number(order_id="ord-1", tracking_number="TRK-1").status == "tracking-set" + assert order.accept_return_order(order_id="ord-1", postal_office_id="ops-1").status == "return-accepted" def test_labels_delivery_and_stock_flows() -> None: @@ -88,17 +75,16 @@ def handler(request: httpx.Request) -> httpx.Response: transport = make_transport(httpx.MockTransport(handler)) label = OrderLabel(transport, task_id="42") delivery = DeliveryOrder(transport) - sandbox = SandboxDelivery(transport) task = DeliveryTask(transport, task_id="51") stock = Stock(transport) - assert label.create(request=OrderLabelsRequest(order_ids=["ord-1"])).task_id == "42" + assert label.create(order_ids=["ord-1"]).task_id == "42" assert label.download().binary.content == pdf_bytes - assert delivery.create_announcement(request=DeliveryAnnouncementRequest(order_id="ord-1")).task_id == "11" - assert delivery.create(request=DeliveryParcelRequest(order_id="ord-1", parcel_id="par-1")).parcel_id == "par-1" - assert delivery.delete(request=DeliveryAnnouncementRequest(order_id="ord-1")).status == "announcement-cancelled" - assert delivery.create_change_parcel_result(request=DeliveryParcelResultRequest(parcel_id="par-1", result="ok")).status == "callback-accepted" - assert delivery.update_change_parcels(request=DeliveryParcelIdsRequest(parcel_ids=["par-1"])).status == "parcels-updated" + assert delivery.create_announcement(order_id="ord-1").task_id == "11" + assert delivery.create(order_id="ord-1", parcel_id="par-1").parcel_id == "par-1" + assert delivery.delete(order_id="ord-1").status == "announcement-cancelled" + assert delivery.create_change_parcel_result(parcel_id="par-1", result="ok").status == "callback-accepted" + assert delivery.update_change_parcels(parcel_ids=["par-1"]).status == "parcels-updated" assert task.get().status == "done" - assert stock.get(request=StockInfoRequest(item_ids=[123321])).items[0].quantity == 5 - assert stock.update(request=StockUpdateRequest(stocks=[StockUpdateEntry(item_id=123321, quantity=7)])).items[0].success is True + assert stock.get(item_ids=[123321]).items[0].quantity == 5 + assert stock.update(stocks=[StockUpdateEntry(item_id=123321, quantity=7)]).items[0].success is True diff --git a/tests/domains/promotion/test_promotion.py b/tests/domains/promotion/test_promotion.py index f251039..b024fec 100644 --- a/tests/domains/promotion/test_promotion.py +++ b/tests/domains/promotion/test_promotion.py @@ -7,11 +7,17 @@ import pytest from avito.ads import AdPromotion -from avito.core import ResponseMappingError -from avito.promotion import AutostrategyCampaign, BbipPromotion, CpaAuction, PromotionOrder, TargetActionPricing, TrxPromotion +from avito.core import ResponseMappingError, ValidationError +from avito.promotion import ( + AutostrategyCampaign, + BbipPromotion, + CpaAuction, + PromotionOrder, + TargetActionPricing, + TrxPromotion, +) from avito.promotion.models import ( BbipItem, - CreateItemBid, ) from tests.helpers.transport import make_transport @@ -133,6 +139,29 @@ def handler(request: httpx.Request) -> httpx.Response: assert campaign.get_stat().totals is not None +def test_autostrategy_datetime_parameters_fail_fast_on_invalid_type() -> None: + campaign = AutostrategyCampaign( + make_transport(httpx.MockTransport(lambda request: httpx.Response(500))), + campaign_id=77, + ) + + with pytest.raises(ValidationError, match="`start_time` должен быть datetime."): + 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."): + 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."): + campaign.update( + version=3, + start_time="2026-04-20T00:00:00+00:00", # type: ignore[arg-type] + ) + + def test_promotion_write_methods_keep_same_payload_in_dry_run_mode() -> None: seen: list[tuple[str, dict[str, object]]] = [] diff --git a/tests/domains/realty/test_realty.py b/tests/domains/realty/test_realty.py index df22a25..e8d4472 100644 --- a/tests/domains/realty/test_realty.py +++ b/tests/domains/realty/test_realty.py @@ -9,7 +9,6 @@ RealtyBaseParamsUpdateRequest, RealtyBookingsUpdateRequest, RealtyInterval, - RealtyIntervalsRequest, RealtyPricePeriod, RealtyPricesUpdateRequest, ) @@ -49,7 +48,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert bookings.items[0].contact is not None assert bookings.items[0].contact.name == "Иван" assert pricing.update_realty_prices(request=RealtyPricesUpdateRequest(periods=[RealtyPricePeriod(date_from="2026-05-01", price=5000)])).status == "success" - assert listing.get_intervals(request=RealtyIntervalsRequest(item_id=20, intervals=[RealtyInterval(date="2026-05-01", available=True)])).success is True + assert listing.get_intervals(intervals=[RealtyInterval(date="2026-05-01", available=True)]).success is True assert listing.update_base_params(request=RealtyBaseParamsUpdateRequest(min_stay_days=2)).success is True assert analytics.get_market_price_correspondence(price=5000000).correspondence == "normal" assert analytics.get_report_for_classified().report_link == "https://example.com/realty-report/20"