diff --git a/.claude/settings.local.json b/.claude/settings.local.json index eda3e29..9b5698e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -16,7 +16,10 @@ "WebSearch", "WebFetch(domain:azure.github.io)", "WebFetch(domain:medium.com)", - "WebFetch(domain:newsletter.pragmaticengineer.com)" + "WebFetch(domain:newsletter.pragmaticengineer.com)", + "Bash(awk '{print $NF}')", + "Bash(grep \"| $\" /Users/n.baryshnikov/Projects/avito_python_api/docs/inventory.md)", + "Bash(grep -r \"def.*:$\" /Users/n.baryshnikov/Projects/avito_python_api/avito --include=\"*.py\")" ] } } diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1269823 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on Keep a Changelog, +and this project adheres to Semantic Versioning. + +## [Unreleased] + +### Changed +- Централизовано выполнение схемы `request + map` через `Transport.request_public_model`. +- Убраны прямые обращения доменных клиентов к `request_json` и приватному `Transport._auth_provider`. +- Секционные клиенты переведены на `@dataclass(slots=True, frozen=True)`. +- Иерархия исключений упрощена до frozen dataclass без кастомного `__setattr__`. +- Публичные сигнатуры `accounts`, `ads`, `autoteka`, `cpa`, `jobs`, `messenger`, `orders`, `promotion`, `ratings` и `realty` переведены с `request`-DTO на keyword-only примитивы и коллекции. +- Transport получил поддержку `Idempotency-Key`; публичные write-методы во всех доменах принимают `idempotency_key`, а dry-run/write-контракт promotion покрыт тестами. +- Во всех доменных пакетах добавлены `enums.py`; `accounts`, `ads`, `autoteka`, `jobs`, `messenger`, `orders`, `promotion`, `ratings`, `realty` и `tariffs` переведены на typed enums с fallback на `UNKNOWN` и warning-логом ровно один раз на неизвестное upstream-значение. + +## [1.0.2] - 2026-04-21 + +### Added +- Первый публичный релиз changelog для `avito-py`. + +### Changed +- Зафиксирована базовая структура истории изменений для следующих фаз исправления STYLEGUIDE. diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index 2d3ab50..9d8df4b 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -339,7 +339,7 @@ All HTTP must go through a single transport layer. Rules: - 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. +- Use `httpx.Client` 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. @@ -349,7 +349,6 @@ Recommendation: - 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. ### User-Agent and Client Identification @@ -363,19 +362,6 @@ Recommendation: - Overrides must not mutate the client or shared state. The transport layer resolves the effective policy as `override or client_default` without writing back. - The list of supported per-operation overrides is part of the public contract and must be documented on each public method. -## Async and Sync Parity - -When an async surface is added it must be a separate, parallel layer, not a retrofit of sync classes. - -Rules: - -- The async namespace is `avito.aio` with mirrored module paths: `avito.aio.client`, `avito.aio..client`. -- Async client classes have the same names as their sync counterparts (`AvitoClient`, `AdClient`, ...) but live in the `aio` namespace. -- Public method names and parameter lists must be identical between sync and async. Only the call-site keyword (`await`) and the context-manager form (`async with`) differ. -- Mixing `async def` and `def` on the same class is forbidden. -- Return types are the same public dataclasses in both layers. `PaginatedList[T]` has an `AsyncPaginatedList[T]` sibling with matching semantics and matching `materialize()`. -- Feature parity between sync and async is part of the public contract: a method that exists in sync must exist in async within the same release, or be explicitly marked sync-only in the documentation. - ## Authorization Authorization must be fully abstracted away from API methods. @@ -885,6 +871,5 @@ Rules: - Retrying non-idempotent writes without an idempotency key or an explicit per-operation opt-in. - Raising on expected "not found" for probe methods (`exists()` must return `False`, not throw). - Exposing `FakeTransport` only through internal test imports instead of `avito.testing`. -- Mixing sync and async surfaces in the same class or module (`avito.aio` is the only home for async). - Public methods that swallow upstream `request_id` or retry `attempt` in raised exceptions. - Documentation that covers only reference or only tutorials (all four Diátaxis modes are mandatory). diff --git a/avito/__init__.py b/avito/__init__.py index d4de0b3..c84e251 100644 --- a/avito/__init__.py +++ b/avito/__init__.py @@ -3,5 +3,35 @@ from avito.auth.settings import AuthSettings from avito.client import AvitoClient from avito.config import AvitoSettings +from avito.core.exceptions import ( + AuthenticationError, + AuthorizationError, + AvitoError, + ConfigurationError, + ConflictError, + RateLimitError, + ResponseMappingError, + TransportError, + UnsupportedOperationError, + UpstreamApiError, + ValidationError, +) +from avito.core.pagination import PaginatedList -__all__ = ("AuthSettings", "AvitoClient", "AvitoSettings") +__all__ = ( + "AuthSettings", + "AuthenticationError", + "AuthorizationError", + "AvitoClient", + "AvitoError", + "AvitoSettings", + "ConfigurationError", + "ConflictError", + "PaginatedList", + "RateLimitError", + "ResponseMappingError", + "TransportError", + "UnsupportedOperationError", + "UpstreamApiError", + "ValidationError", +) diff --git a/avito/accounts/__init__.py b/avito/accounts/__init__.py index e398550..8922339 100644 --- a/avito/accounts/__init__.py +++ b/avito/accounts/__init__.py @@ -1,6 +1,12 @@ """Пакет accounts.""" from avito.accounts.domain import Account, AccountHierarchy +from avito.accounts.enums import ( + AccountHierarchyRole, + EmployeeItemStatus, + OperationStatus, + OperationType, +) from avito.accounts.models import ( AccountActionResult, AccountBalance, @@ -19,12 +25,16 @@ "AccountActionResult", "AccountBalance", "AccountHierarchy", + "AccountHierarchyRole", "AccountProfile", "AhUserStatus", "CompanyPhone", "CompanyPhonesResult", "Employee", "EmployeeItem", + "EmployeeItemStatus", "EmployeesResult", "OperationRecord", + "OperationStatus", + "OperationType", ) diff --git a/avito/accounts/client.py b/avito/accounts/client.py index d1f72e7..ff26862 100644 --- a/avito/accounts/client.py +++ b/avito/accounts/client.py @@ -31,7 +31,7 @@ from avito.core.mapping import request_public_model -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class AccountsClient: """Выполняет HTTP-операции по разделу информации о пользователе.""" @@ -59,18 +59,25 @@ def get_balance(self, *, user_id: int) -> AccountBalance: mapper=map_account_balance, ) - def get_operations_history(self, request: OperationsHistoryRequest) -> PaginatedList[OperationRecord]: + def get_operations_history( + self, + *, + date_from: str | None = None, + date_to: str | None = None, + limit: int | None = None, + offset: int | None = None, + ) -> PaginatedList[OperationRecord]: """Получает историю операций пользователя.""" - page_size = request.limit or 25 - base_offset = request.offset or 0 + page_size = limit or 25 + base_offset = 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, + date_from=date_from, + date_to=date_to, limit=page_size, offset=current_offset, ) @@ -92,7 +99,7 @@ def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[OperationRecor return Paginator(fetch_page).as_list(first_page=fetch_page(1, None)) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class HierarchyClient: """Выполняет HTTP-операции по иерархии аккаунтов.""" @@ -131,28 +138,49 @@ def list_company_phones(self) -> CompanyPhonesResult: mapper=map_company_phones, ) - def link_items(self, request: EmployeeItemLinkRequest) -> AccountActionResult: + def link_items( + self, + *, + employee_id: int, + item_ids: list[int], + source_employee_id: int | None = None, + idempotency_key: str | None = None, + ) -> AccountActionResult: """Прикрепляет объявления к сотруднику.""" return request_public_model( self.transport, "POST", "/linkItemsV1", - context=RequestContext("accounts.hierarchy.link_items", allow_retry=True), + context=RequestContext( + "accounts.hierarchy.link_items", + allow_retry=idempotency_key is not None, + ), mapper=map_action_result, - json_body=request.to_payload(), + json_body=EmployeeItemLinkRequest( + employee_id=employee_id, + item_ids=item_ids, + source_employee_id=source_employee_id, + ).to_payload(), + idempotency_key=idempotency_key, ) - def list_items_by_employee(self, request: EmployeeItemsRequest) -> PaginatedList[EmployeeItem]: + def list_items_by_employee( + self, + *, + employee_id: int, + limit: int | None = None, + offset: int | None = None, + ) -> PaginatedList[EmployeeItem]: """Получает список объявлений по сотруднику.""" - page_size = request.limit or 25 + page_size = 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 + current_offset = (offset or 0) + (current_page - 1) * page_size paged_request = EmployeeItemsRequest( - employee_id=request.employee_id, + employee_id=employee_id, limit=page_size, offset=current_offset, ) diff --git a/avito/accounts/domain.py b/avito/accounts/domain.py index 74abbc4..16d83b1 100644 --- a/avito/accounts/domain.py +++ b/avito/accounts/domain.py @@ -14,11 +14,8 @@ AhUserStatus, CompanyPhonesResult, EmployeeItem, - EmployeeItemLinkRequest, - EmployeeItemsRequest, EmployeesResult, OperationRecord, - OperationsHistoryRequest, ) from avito.core import PaginatedList, ValidationError from avito.core.domain import DomainObject @@ -58,12 +55,10 @@ def get_operations_history( """Получает историю операций пользователя.""" return AccountsClient(self.transport).get_operations_history( - OperationsHistoryRequest( - date_from=_serialize_datetime(date_from), - date_to=_serialize_datetime(date_to), - limit=limit, - offset=offset, - ) + date_from=_serialize_datetime(date_from), + date_to=_serialize_datetime(date_to), + limit=limit, + offset=offset, ) @@ -94,15 +89,15 @@ def link_items( employee_id: int, item_ids: Sequence[int], source_employee_id: int | None = None, + idempotency_key: str | None = None, ) -> AccountActionResult: """Прикрепляет объявления к сотруднику.""" return HierarchyClient(self.transport).link_items( - EmployeeItemLinkRequest( - employee_id=employee_id, - item_ids=list(item_ids), - source_employee_id=source_employee_id, - ) + employee_id=employee_id, + item_ids=list(item_ids), + source_employee_id=source_employee_id, + idempotency_key=idempotency_key, ) def list_items_by_employee( @@ -115,7 +110,9 @@ def list_items_by_employee( """Получает список объявлений сотрудника.""" return HierarchyClient(self.transport).list_items_by_employee( - EmployeeItemsRequest(employee_id=employee_id, limit=limit, offset=offset) + employee_id=employee_id, + limit=limit, + offset=offset, ) diff --git a/avito/accounts/enums.py b/avito/accounts/enums.py new file mode 100644 index 0000000..89c2c9e --- /dev/null +++ b/avito/accounts/enums.py @@ -0,0 +1,41 @@ +"""Enum-значения раздела accounts.""" + +from __future__ import annotations + +from enum import Enum + + +class OperationType(str, Enum): + """Тип операции по аккаунту.""" + + UNKNOWN = "__unknown__" + PAYMENT = "payment" + + +class OperationStatus(str, Enum): + """Статус операции по аккаунту.""" + + UNKNOWN = "__unknown__" + DONE = "done" + + +class AccountHierarchyRole(str, Enum): + """Роль пользователя в иерархии аккаунтов.""" + + UNKNOWN = "__unknown__" + MANAGER = "manager" + + +class EmployeeItemStatus(str, Enum): + """Статус объявления сотрудника.""" + + UNKNOWN = "__unknown__" + ACTIVE = "active" + + +__all__ = ( + "AccountHierarchyRole", + "EmployeeItemStatus", + "OperationStatus", + "OperationType", +) diff --git a/avito/accounts/mappers.py b/avito/accounts/mappers.py index 418495a..b802345 100644 --- a/avito/accounts/mappers.py +++ b/avito/accounts/mappers.py @@ -6,6 +6,12 @@ from datetime import datetime from typing import cast +from avito.accounts.enums import ( + AccountHierarchyRole, + EmployeeItemStatus, + OperationStatus, + OperationType, +) from avito.accounts.models import ( AccountActionResult, AccountBalance, @@ -20,6 +26,7 @@ OperationRecord, OperationsHistoryResult, ) +from avito.core.enums import map_enum_or_unknown from avito.core.exceptions import ResponseMappingError Payload = Mapping[str, object] @@ -129,8 +136,16 @@ def map_operations_history(payload: object) -> OperationsHistoryResult: id=_as_str(item, "id", "operation_id"), 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"), + operation_type=map_enum_or_unknown( + _as_str(item, "type", "operation_type", "operationType"), + OperationType, + enum_name="accounts.operation_type", + ), + status=map_enum_or_unknown( + _as_str(item, "status"), + OperationStatus, + enum_name="accounts.operation_status", + ), description=_as_str(item, "description", "title"), ) for item in _as_list(data, "operations", "items", "result") @@ -148,7 +163,11 @@ def map_ah_user_status(payload: object) -> AhUserStatus: return 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"), + role=map_enum_or_unknown( + _as_str(data, "role", "status"), + AccountHierarchyRole, + enum_name="accounts.account_hierarchy_role", + ), ) @@ -192,7 +211,11 @@ def map_employee_items(payload: object) -> EmployeeItemsResult: EmployeeItem( item_id=_as_int(item, "item_id", "itemId", "id"), title=_as_str(item, "title"), - status=_as_str(item, "status"), + status=map_enum_or_unknown( + _as_str(item, "status"), + EmployeeItemStatus, + enum_name="accounts.employee_item_status", + ), price=_as_float(item, "price"), ) for item in _as_list(data, "items", "result") diff --git a/avito/accounts/models.py b/avito/accounts/models.py index 8bf83e3..3ba6a10 100644 --- a/avito/accounts/models.py +++ b/avito/accounts/models.py @@ -5,6 +5,12 @@ from dataclasses import dataclass from datetime import datetime +from avito.accounts.enums import ( + AccountHierarchyRole, + EmployeeItemStatus, + OperationStatus, + OperationType, +) from avito.core.serialization import SerializableModel @@ -36,8 +42,8 @@ class OperationRecord(SerializableModel): id: str | None created_at: datetime | None amount: float | None - operation_type: str | None - status: str | None + operation_type: OperationType | None + status: OperationStatus | None description: str | None @@ -79,7 +85,7 @@ class AhUserStatus(SerializableModel): user_id: int | None is_active: bool | None - role: str | None + role: AccountHierarchyRole | None @dataclass(slots=True, frozen=True) @@ -167,7 +173,7 @@ class EmployeeItem(SerializableModel): item_id: int | None title: str | None - status: str | None + status: EmployeeItemStatus | None price: float | None diff --git a/avito/ads/__init__.py b/avito/ads/__init__.py index 3375431..3cc08dd 100644 --- a/avito/ads/__init__.py +++ b/avito/ads/__init__.py @@ -8,6 +8,7 @@ AutoloadProfile, AutoloadReport, ) +from avito.ads.enums import AdsActionStatus, AutoloadFieldType, AutoloadReportStatus, ListingStatus from avito.ads.models import ( AccountSpendings, AdsActionResult, @@ -43,9 +44,11 @@ "Ad", "AdsActionResult", "AdsListResult", + "AdsActionStatus", "AdPromotion", "AdStats", "AutoloadArchive", + "AutoloadFieldType", "AutoloadFee", "AutoloadFeesResult", "AutoloadField", @@ -58,6 +61,7 @@ "AutoloadReportItemsResult", "AutoloadReportSummary", "AutoloadReportsResult", + "AutoloadReportStatus", "AutoloadTreeNode", "AutoloadTreeResult", "CallStats", @@ -66,6 +70,7 @@ "ItemStatsResult", "LegacyAutoloadReport", "Listing", + "ListingStatus", "ListingStats", "SpendingRecord", "UpdatePriceResult", diff --git a/avito/ads/client.py b/avito/ads/client.py index 2991767..79473f3 100644 --- a/avito/ads/client.py +++ b/avito/ads/client.py @@ -4,6 +4,7 @@ from dataclasses import dataclass +from avito.ads.enums import ListingStatus from avito.ads.mappers import ( map_action_result, map_ad_item, @@ -66,7 +67,7 @@ from avito.promotion.models import PromotionActionResult -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class AdsClient: """Выполняет HTTP-операции по разделу объявлений.""" @@ -87,7 +88,7 @@ def list_items( self, *, user_id: int | None = None, - status: str | None = None, + status: ListingStatus | str | None = None, limit: int | None = None, offset: int | None = None, ) -> PaginatedList[Listing]: @@ -130,7 +131,7 @@ def _fetch_ads_page( *, page: int | None, user_id: int | None, - status: str | None, + status: ListingStatus | str | None, page_size: int, ) -> JsonPage[Listing]: if page is None: @@ -157,26 +158,40 @@ def _fetch_ads_page( per_page=page_size, ) - def update_price(self, *, item_id: int, price: UpdatePriceRequest) -> UpdatePriceResult: + def update_price( + self, + *, + item_id: int, + price: int | float, + idempotency_key: str | None = None, + ) -> UpdatePriceResult: """Обновляет цену объявления.""" return request_public_model( self.transport, "POST", f"/core/v1/items/{item_id}/update_price", - context=RequestContext("ads.update_price", allow_retry=True), + context=RequestContext("ads.update_price", allow_retry=idempotency_key is not None), mapper=map_update_price_result, - json_body=price.to_payload(), + json_body=UpdatePriceRequest(price=price).to_payload(), + idempotency_key=idempotency_key, ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class StatsClient: """Выполняет HTTP-операции по статистике объявлений.""" transport: Transport - def get_calls_stats(self, *, user_id: int, request: CallsStatsRequest) -> CallsStatsResult: + def get_calls_stats( + self, + *, + user_id: int, + item_ids: list[int], + date_from: str | None = None, + date_to: str | None = None, + ) -> CallsStatsResult: """Получает статистику звонков.""" return request_public_model( @@ -185,10 +200,22 @@ def get_calls_stats(self, *, user_id: int, request: CallsStatsRequest) -> CallsS 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(), + json_body=CallsStatsRequest( + item_ids=item_ids, + date_from=date_from, + date_to=date_to, + ).to_payload(), ) - def get_item_stats(self, *, user_id: int, request: ItemStatsRequest) -> ItemStatsResult: + def get_item_stats( + self, + *, + user_id: int, + item_ids: list[int], + date_from: str | None = None, + date_to: str | None = None, + fields: list[str] | None = None, + ) -> ItemStatsResult: """Получает статистику по списку объявлений.""" return request_public_model( @@ -197,10 +224,23 @@ def get_item_stats(self, *, user_id: int, request: ItemStatsRequest) -> ItemStat f"/stats/v1/accounts/{user_id}/items", context=RequestContext("ads.stats.items", allow_retry=True), mapper=map_item_stats, - json_body=request.to_payload(), + json_body=ItemStatsRequest( + item_ids=item_ids, + date_from=date_from, + date_to=date_to, + fields=fields or [], + ).to_payload(), ) - def get_item_analytics(self, *, user_id: int, request: ItemStatsRequest) -> ItemAnalyticsResult: + def get_item_analytics( + self, + *, + user_id: int, + item_ids: list[int], + date_from: str | None = None, + date_to: str | None = None, + fields: list[str] | None = None, + ) -> ItemAnalyticsResult: """Получает аналитику по профилю.""" return request_public_model( @@ -209,10 +249,23 @@ def get_item_analytics(self, *, user_id: int, request: ItemStatsRequest) -> Item f"/stats/v2/accounts/{user_id}/items", context=RequestContext("ads.stats.analytics", allow_retry=True), mapper=map_item_analytics, - json_body=request.to_payload(), + json_body=ItemStatsRequest( + item_ids=item_ids, + date_from=date_from, + date_to=date_to, + fields=fields or [], + ).to_payload(), ) - def get_account_spendings(self, *, user_id: int, request: ItemStatsRequest) -> AccountSpendings: + def get_account_spendings( + self, + *, + user_id: int, + item_ids: list[int], + date_from: str | None = None, + date_to: str | None = None, + fields: list[str] | None = None, + ) -> AccountSpendings: """Получает статистику расходов профиля.""" return request_public_model( @@ -221,17 +274,24 @@ def get_account_spendings(self, *, user_id: int, request: ItemStatsRequest) -> A f"/stats/v2/accounts/{user_id}/spendings", context=RequestContext("ads.stats.spendings", allow_retry=True), mapper=map_spendings, - json_body=request.to_payload(), + json_body=ItemStatsRequest( + item_ids=item_ids, + date_from=date_from, + date_to=date_to, + fields=fields or [], + ).to_payload(), ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class VasClient: """Выполняет HTTP-операции VAS и продвижения.""" transport: Transport - def get_prices(self, *, user_id: int, request: VasPricesRequest) -> VasPricesResult: + def get_prices( + self, *, user_id: int, item_ids: list[int], location_id: int | None = None + ) -> VasPricesResult: """Получает цены VAS и доступные услуги продвижения.""" return request_public_model( @@ -240,7 +300,7 @@ def get_prices(self, *, user_id: int, request: VasPricesRequest) -> VasPricesRes 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(), + json_body=VasPricesRequest(item_ids=item_ids, location_id=location_id).to_payload(), ) def apply_item_vas( @@ -248,22 +308,27 @@ def apply_item_vas( *, user_id: int, item_id: int, - request: ApplyVasRequest, + codes: list[str], + idempotency_key: str | None = None, ) -> PromotionActionResult: """Применяет дополнительные услуги к объявлению.""" - payload_to_send = request.to_payload() - payload = self.transport.request_json( + payload_to_send = ApplyVasRequest(codes=codes).to_payload() + return self.transport.request_public_model( "PUT", f"/core/v1/accounts/{user_id}/items/{item_id}/vas", - context=RequestContext("ads.vas.apply_item_vas", allow_retry=True), + context=RequestContext( + "ads.vas.apply_item_vas", + allow_retry=idempotency_key is not None, + ), + mapper=lambda payload: map_promotion_action( + payload, + action="apply_vas", + target={"item_id": item_id, "user_id": user_id}, + request_payload=payload_to_send, + ), json_body=payload_to_send, - ) - return map_promotion_action( - payload, - action="apply_vas", - target={"item_id": item_id, "user_id": user_id}, - request_payload=payload_to_send, + idempotency_key=idempotency_key, ) def apply_item_vas_package( @@ -271,48 +336,55 @@ def apply_item_vas_package( *, user_id: int, item_id: int, - request: ApplyVasPackageRequest, + package_code: str, + idempotency_key: str | None = None, ) -> PromotionActionResult: """Применяет пакет дополнительных услуг.""" - payload_to_send = request.to_payload() - payload = self.transport.request_json( + payload_to_send = ApplyVasPackageRequest(package_code=package_code).to_payload() + return self.transport.request_public_model( "PUT", f"/core/v2/accounts/{user_id}/items/{item_id}/vas_packages", - context=RequestContext("ads.vas.apply_item_vas_package", allow_retry=True), + context=RequestContext( + "ads.vas.apply_item_vas_package", + allow_retry=idempotency_key is not None, + ), + mapper=lambda payload: map_promotion_action( + payload, + action="apply_vas_package", + target={"item_id": item_id, "user_id": user_id}, + request_payload=payload_to_send, + ), json_body=payload_to_send, - ) - return map_promotion_action( - payload, - action="apply_vas_package", - target={"item_id": item_id, "user_id": user_id}, - request_payload=payload_to_send, + idempotency_key=idempotency_key, ) def apply_vas_direct( self, *, item_id: int, - request: ApplyVasRequest, + codes: list[str], + idempotency_key: str | None = None, ) -> PromotionActionResult: """Применяет услуги продвижения через v2 endpoint.""" - payload_to_send = request.to_payload() - payload = self.transport.request_json( + payload_to_send = ApplyVasRequest(codes=codes).to_payload() + return self.transport.request_public_model( "PUT", f"/core/v2/items/{item_id}/vas/", - context=RequestContext("ads.vas.apply_direct", allow_retry=True), + context=RequestContext("ads.vas.apply_direct", allow_retry=idempotency_key is not None), + mapper=lambda payload: map_promotion_action( + payload, + action="apply_vas_direct", + target={"item_id": item_id}, + request_payload=payload_to_send, + ), json_body=payload_to_send, - ) - return map_promotion_action( - payload, - action="apply_vas_direct", - target={"item_id": item_id}, - request_payload=payload_to_send, + idempotency_key=idempotency_key, ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class AutoloadClient: """Выполняет HTTP-операции автозагрузки.""" @@ -329,28 +401,44 @@ def get_profile(self) -> AutoloadProfileSettings: mapper=map_autoload_profile, ) - def save_profile(self, request: AutoloadProfileUpdateRequest) -> AdsActionResult: + def save_profile( + self, + *, + is_enabled: bool | None = None, + email: str | None = None, + callback_url: str | None = None, + idempotency_key: str | None = None, + ) -> AdsActionResult: """Создает или редактирует профиль автозагрузки.""" return request_public_model( self.transport, "POST", "/autoload/v2/profile", - context=RequestContext("ads.autoload.save_profile", allow_retry=True), + context=RequestContext("ads.autoload.save_profile", allow_retry=idempotency_key is not None), mapper=map_action_result, - json_body=request.to_payload(), + json_body=AutoloadProfileUpdateRequest( + is_enabled=is_enabled, + email=email, + callback_url=callback_url, + ).to_payload(), + idempotency_key=idempotency_key, ) - def upload_by_url(self, request: UploadByUrlRequest) -> UploadResult: + def upload_by_url(self, *, url: str, idempotency_key: str | None = None) -> UploadResult: """Запускает загрузку файла по ссылке.""" return request_public_model( self.transport, "POST", "/autoload/v1/upload", - context=RequestContext("ads.autoload.upload_by_url", allow_retry=True), + context=RequestContext( + "ads.autoload.upload_by_url", + allow_retry=idempotency_key is not None, + ), mapper=map_upload_result, - json_body=request.to_payload(), + json_body=UploadByUrlRequest(url=url).to_payload(), + idempotency_key=idempotency_key, ) def get_node_fields(self, *, node_slug: str) -> AutoloadFieldsResult: @@ -484,7 +572,7 @@ def get_report_fees(self, *, report_id: int) -> AutoloadFeesResult: ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class AutoloadArchiveClient: """Выполняет архивные HTTP-операции автозагрузки.""" @@ -501,16 +589,31 @@ def get_profile(self) -> AutoloadProfileSettings: mapper=map_autoload_profile, ) - def save_profile(self, request: AutoloadProfileUpdateRequest) -> AdsActionResult: + def save_profile( + self, + *, + is_enabled: bool | None = None, + email: str | None = None, + callback_url: str | None = None, + idempotency_key: str | None = None, + ) -> AdsActionResult: """Создает или редактирует архивный профиль автозагрузки.""" return request_public_model( self.transport, "POST", "/autoload/v1/profile", - context=RequestContext("ads.autoload_archive.save_profile", allow_retry=True), + context=RequestContext( + "ads.autoload_archive.save_profile", + allow_retry=idempotency_key is not None, + ), mapper=map_action_result, - json_body=request.to_payload(), + json_body=AutoloadProfileUpdateRequest( + is_enabled=is_enabled, + email=email, + callback_url=callback_url, + ).to_payload(), + idempotency_key=idempotency_key, ) def get_last_completed_report(self) -> LegacyAutoloadReport: diff --git a/avito/ads/domain.py b/avito/ads/domain.py index 837f65f..f411a98 100644 --- a/avito/ads/domain.py +++ b/avito/ads/domain.py @@ -13,6 +13,7 @@ StatsClient, VasClient, ) +from avito.ads.enums import ListingStatus from avito.ads.models import ( AccountSpendings, AdsActionResult, @@ -21,24 +22,18 @@ AutoloadFeesResult, AutoloadFieldsResult, AutoloadProfileSettings, - AutoloadProfileUpdateRequest, AutoloadReportDetails, AutoloadReportItemsResult, AutoloadReportSummary, AutoloadTreeResult, - CallsStatsRequest, CallsStatsResult, IdMappingResult, ItemAnalyticsResult, - ItemStatsRequest, ItemStatsResult, LegacyAutoloadReport, Listing, - UpdatePriceRequest, UpdatePriceResult, - UploadByUrlRequest, UploadResult, - VasPricesRequest, VasPricesResult, ) from avito.core import PaginatedList, ValidationError @@ -47,6 +42,7 @@ validate_non_empty_string, validate_string_items, ) +from avito.promotion.enums import PromotionStatus from avito.promotion.models import PromotionActionResult @@ -59,7 +55,7 @@ def _preview_result( return PromotionActionResult( action=action, target=target, - status="preview", + status=PromotionStatus.PREVIEW, applied=False, request_payload=request_payload, details={"validated": True}, @@ -84,7 +80,11 @@ def get(self) -> Listing: return AdsClient(self.transport).get_item(user_id=user_id, item_id=item_id) def list( - self, *, status: str | None = None, limit: int | None = None, offset: int | None = None + self, + *, + status: ListingStatus | str | None = None, + limit: int | None = None, + offset: int | None = None, ) -> PaginatedList[Listing]: """Получает список объявлений.""" @@ -93,12 +93,19 @@ def list( user_id=user_id, status=status, limit=limit, offset=offset ) - def update_price(self, *, price: int | float) -> UpdatePriceResult: + def update_price( + self, + *, + price: int | float, + idempotency_key: str | None = None, + ) -> UpdatePriceResult: """Обновляет цену текущего объявления.""" item_id = self._require_item_id() return AdsClient(self.transport).update_price( - item_id=item_id, price=UpdatePriceRequest(price=price) + item_id=item_id, + price=price, + idempotency_key=idempotency_key, ) def _require_item_id(self) -> int: @@ -134,11 +141,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=_serialize_datetime(date_from), - date_to=_serialize_datetime(date_to), - ), + item_ids=resolved_item_ids, + date_from=_serialize_datetime(date_from), + date_to=_serialize_datetime(date_to), ) def get_item_stats( @@ -157,12 +162,10 @@ def get_item_stats( ) return StatsClient(self.transport).get_item_stats( user_id=user_id, - request=ItemStatsRequest( - item_ids=resolved_item_ids, - date_from=_serialize_datetime(date_from), - date_to=_serialize_datetime(date_to), - fields=fields or [], - ), + item_ids=resolved_item_ids, + date_from=_serialize_datetime(date_from), + date_to=_serialize_datetime(date_to), + fields=fields or [], ) def get_item_analytics( @@ -181,12 +184,10 @@ def get_item_analytics( ) return StatsClient(self.transport).get_item_analytics( user_id=user_id, - request=ItemStatsRequest( - item_ids=resolved_item_ids, - date_from=_serialize_datetime(date_from), - date_to=_serialize_datetime(date_to), - fields=fields or [], - ), + item_ids=resolved_item_ids, + date_from=_serialize_datetime(date_from), + date_to=_serialize_datetime(date_to), + fields=fields or [], ) def get_account_spendings( @@ -205,12 +206,10 @@ def get_account_spendings( ) return StatsClient(self.transport).get_account_spendings( user_id=user_id, - request=ItemStatsRequest( - item_ids=resolved_item_ids, - date_from=_serialize_datetime(date_from), - date_to=_serialize_datetime(date_to), - fields=fields or [], - ), + item_ids=resolved_item_ids, + date_from=_serialize_datetime(date_from), + date_to=_serialize_datetime(date_to), + fields=fields or [], ) def _require_user_id(self) -> int: @@ -234,7 +233,8 @@ def get_vas_prices( user_id = self._require_user_id() return VasClient(self.transport).get_prices( user_id=user_id, - request=VasPricesRequest(item_ids=item_ids, location_id=location_id), + item_ids=item_ids, + location_id=location_id, ) def apply_vas( @@ -242,13 +242,13 @@ def apply_vas( *, codes: list[str], dry_run: bool = False, + idempotency_key: str | None = None, ) -> PromotionActionResult: """Применяет дополнительные услуги к объявлению.""" item_id, user_id = self._require_ids() validate_string_items("codes", codes) - request = ApplyVasRequest(codes=codes) - request_payload = request.to_payload() + request_payload = ApplyVasRequest(codes=codes).to_payload() target: dict[str, object] = {"item_id": item_id, "user_id": user_id} if dry_run: return _preview_result( @@ -259,7 +259,8 @@ def apply_vas( return VasClient(self.transport).apply_item_vas( user_id=user_id, item_id=item_id, - request=request, + codes=codes, + idempotency_key=idempotency_key, ) def apply_vas_package( @@ -267,13 +268,13 @@ def apply_vas_package( *, package_code: str, dry_run: bool = False, + idempotency_key: str | None = None, ) -> 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() + request_payload = ApplyVasPackageRequest(package_code=package_code).to_payload() target: dict[str, object] = {"item_id": item_id, "user_id": user_id} if dry_run: return _preview_result( @@ -284,7 +285,8 @@ def apply_vas_package( return VasClient(self.transport).apply_item_vas_package( user_id=user_id, item_id=item_id, - request=request, + package_code=package_code, + idempotency_key=idempotency_key, ) def apply_vas_direct( @@ -292,13 +294,13 @@ def apply_vas_direct( *, codes: list[str], dry_run: bool = False, + idempotency_key: str | None = None, ) -> PromotionActionResult: """Применяет услуги продвижения через прямой v2 endpoint.""" item_id = self._require_item_id() validate_string_items("codes", codes) - request = ApplyVasRequest(codes=codes) - request_payload = request.to_payload() + request_payload = ApplyVasRequest(codes=codes).to_payload() target: dict[str, object] = {"item_id": item_id} if dry_run: return _preview_result( @@ -308,7 +310,8 @@ def apply_vas_direct( ) return VasClient(self.transport).apply_vas_direct( item_id=item_id, - request=request, + codes=codes, + idempotency_key=idempotency_key, ) def _require_item_id(self) -> int: @@ -342,19 +345,24 @@ def save( is_enabled: bool | None = None, email: str | None = None, callback_url: str | None = None, + idempotency_key: str | None = None, ) -> AdsActionResult: """Сохраняет профиль автозагрузки.""" return AutoloadClient(self.transport).save_profile( - AutoloadProfileUpdateRequest( - is_enabled=is_enabled, email=email, callback_url=callback_url - ) + is_enabled=is_enabled, + email=email, + callback_url=callback_url, + idempotency_key=idempotency_key, ) - def upload_by_url(self, *, url: str) -> UploadResult: + def upload_by_url(self, *, url: str, idempotency_key: str | None = None) -> UploadResult: """Загружает файл по ссылке.""" - return AutoloadClient(self.transport).upload_by_url(UploadByUrlRequest(url=url)) + return AutoloadClient(self.transport).upload_by_url( + url=url, + idempotency_key=idempotency_key, + ) def get_tree(self) -> AutoloadTreeResult: """Получает дерево категорий.""" @@ -441,13 +449,15 @@ def save_profile( is_enabled: bool | None = None, email: str | None = None, callback_url: str | None = None, + idempotency_key: str | None = None, ) -> AdsActionResult: """Сохраняет архивный профиль автозагрузки.""" return AutoloadArchiveClient(self.transport).save_profile( - AutoloadProfileUpdateRequest( - is_enabled=is_enabled, email=email, callback_url=callback_url - ) + is_enabled=is_enabled, + email=email, + callback_url=callback_url, + idempotency_key=idempotency_key, ) def get_last_completed_report(self) -> LegacyAutoloadReport: diff --git a/avito/ads/enums.py b/avito/ads/enums.py new file mode 100644 index 0000000..a57d686 --- /dev/null +++ b/avito/ads/enums.py @@ -0,0 +1,42 @@ +"""Enum-значения раздела ads.""" + +from __future__ import annotations + +from enum import Enum + + +class ListingStatus(str, Enum): + """Статус объявления.""" + + UNKNOWN = "__unknown__" + ACTIVE = "active" + + +class AdsActionStatus(str, Enum): + """Статус мутационной операции ads.""" + + UNKNOWN = "__unknown__" + APPLIED = "applied" + UPDATED = "updated" + + +class AutoloadFieldType(str, Enum): + """Тип поля автозагрузки.""" + + UNKNOWN = "__unknown__" + STRING = "string" + + +class AutoloadReportStatus(str, Enum): + """Статус отчета автозагрузки.""" + + UNKNOWN = "__unknown__" + DONE = "done" + + +__all__ = ( + "AdsActionStatus", + "AutoloadFieldType", + "AutoloadReportStatus", + "ListingStatus", +) diff --git a/avito/ads/mappers.py b/avito/ads/mappers.py index e3819b4..b685822 100644 --- a/avito/ads/mappers.py +++ b/avito/ads/mappers.py @@ -6,6 +6,7 @@ from datetime import datetime from typing import cast +from avito.ads.enums import AdsActionStatus, AutoloadFieldType, AutoloadReportStatus, ListingStatus from avito.ads.models import ( AccountSpendings, AdsActionResult, @@ -37,6 +38,7 @@ VasPrice, VasPricesResult, ) +from avito.core.enums import map_enum_or_unknown from avito.core.exceptions import ResponseMappingError Payload = Mapping[str, object] @@ -113,7 +115,11 @@ def map_ad_item(payload: object) -> Listing: user_id=_int(data, "user_id", "userId"), title=_str(data, "title"), description=_str(data, "description"), - status=_str(data, "status"), + status=map_enum_or_unknown( + _str(data, "status"), + ListingStatus, + enum_name="ads.listing_status", + ), price=_float(data, "price"), url=_str(data, "url", "link"), ) @@ -134,7 +140,11 @@ def map_update_price_result(payload: object) -> UpdatePriceResult: return UpdatePriceResult( item_id=_int(data, "item_id", "itemId", "id"), price=_float(data, "price"), - status=_str(data, "status", "result"), + status=map_enum_or_unknown( + _str(data, "status", "result"), + AdsActionStatus, + enum_name="ads.action_status", + ), ) @@ -222,7 +232,11 @@ def map_vas_apply_result(payload: object) -> VasApplyResult: data = _expect_mapping(payload) return VasApplyResult( success=bool(data.get("success", True)), - status=_str(data, "status", "result", "message"), + status=map_enum_or_unknown( + _str(data, "status", "result", "message"), + AdsActionStatus, + enum_name="ads.action_status", + ), ) @@ -255,7 +269,11 @@ def map_autoload_fields(payload: object) -> AutoloadFieldsResult: AutoloadField( slug=_str(item, "slug", "code", "id"), title=_str(item, "title", "name"), - type=_str(item, "type"), + type=map_enum_or_unknown( + _str(item, "type"), + AutoloadFieldType, + enum_name="ads.autoload_field_type", + ), required=_bool(item, "required", "is_required", "isRequired"), ) for item in _list(data, "fields", "items", "result") @@ -292,7 +310,11 @@ def map_id_mapping(payload: object) -> IdMappingResult: def _map_report_summary(item: Payload) -> AutoloadReportSummary: return AutoloadReportSummary( report_id=_int(item, "report_id", "reportId", "id"), - status=_str(item, "status"), + status=map_enum_or_unknown( + _str(item, "status"), + AutoloadReportStatus, + enum_name="ads.autoload_report_status", + ), created_at=_datetime(item, "created_at", "createdAt"), finished_at=_datetime(item, "finished_at", "finishedAt"), processed_items=_int(item, "processed_items", "processedItems", "items"), @@ -315,7 +337,11 @@ def map_autoload_report_details(payload: object) -> AutoloadReportDetails: data = _expect_mapping(payload) return AutoloadReportDetails( report_id=_int(data, "report_id", "reportId", "id"), - status=_str(data, "status"), + status=map_enum_or_unknown( + _str(data, "status"), + AutoloadReportStatus, + enum_name="ads.autoload_report_status", + ), created_at=_datetime(data, "created_at", "createdAt"), finished_at=_datetime(data, "finished_at", "finishedAt"), errors_count=_int(data, "errors_count", "errorsCount"), @@ -329,7 +355,11 @@ def map_legacy_autoload_report(payload: object) -> LegacyAutoloadReport: data = _expect_mapping(payload) return LegacyAutoloadReport( report_id=_int(data, "report_id", "reportId", "id"), - status=_str(data, "status"), + status=map_enum_or_unknown( + _str(data, "status"), + AutoloadReportStatus, + enum_name="ads.autoload_report_status", + ), ) @@ -341,7 +371,11 @@ def map_autoload_report_items(payload: object) -> AutoloadReportItemsResult: AutoloadReportItem( item_id=_int(item, "item_id", "itemId", "id"), avito_id=_int(item, "avito_id", "avitoId"), - status=_str(item, "status"), + status=map_enum_or_unknown( + _str(item, "status"), + AutoloadReportStatus, + enum_name="ads.autoload_report_status", + ), title=_str(item, "title"), ) for item in _list(data, "items", "result") @@ -374,7 +408,7 @@ def map_action_result(payload: object) -> AdsActionResult: data = cast(Payload, payload) return AdsActionResult( success=bool(data.get("success", True)), - message=_str(data, "message", "status"), + message=_str(data, "message"), ) return AdsActionResult(success=True) diff --git a/avito/ads/models.py b/avito/ads/models.py index 57fa979..b8dee5d 100644 --- a/avito/ads/models.py +++ b/avito/ads/models.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field from datetime import datetime +from avito.ads.enums import AdsActionStatus, AutoloadFieldType, AutoloadReportStatus, ListingStatus from avito.core.serialization import SerializableModel @@ -16,7 +17,7 @@ class Listing(SerializableModel): user_id: int | None title: str | None description: str | None - status: str | None + status: ListingStatus | None price: float | None url: str | None @@ -47,7 +48,7 @@ class UpdatePriceResult(SerializableModel): item_id: int | None price: float | None - status: str | None + status: AdsActionStatus | None @dataclass(slots=True, frozen=True) @@ -197,7 +198,7 @@ class VasApplyResult(SerializableModel): """Результат применения услуг продвижения.""" success: bool - status: str | None = None + status: AdsActionStatus | None = None @dataclass(slots=True, frozen=True) @@ -281,7 +282,7 @@ class AutoloadField(SerializableModel): slug: str | None title: str | None - type: str | None + type: AutoloadFieldType | None required: bool | None @@ -320,7 +321,7 @@ class AutoloadReportSummary(SerializableModel): """Краткая информация по отчету автозагрузки.""" report_id: int | None - status: str | None + status: AutoloadReportStatus | None created_at: datetime | None finished_at: datetime | None processed_items: int | None @@ -340,7 +341,7 @@ class AutoloadReportItem(SerializableModel): item_id: int | None avito_id: int | None - status: str | None + status: AutoloadReportStatus | None title: str | None @@ -374,7 +375,7 @@ class AutoloadReportDetails(SerializableModel): """Детальная информация по отчету автозагрузки.""" report_id: int | None - status: str | None + status: AutoloadReportStatus | None created_at: datetime | None finished_at: datetime | None errors_count: int | None @@ -386,7 +387,7 @@ class LegacyAutoloadReport(SerializableModel): """Legacy-ответ автозагрузки.""" report_id: int | None - status: str | None + status: AutoloadReportStatus | None @dataclass(slots=True, frozen=True) diff --git a/avito/auth/__init__.py b/avito/auth/__init__.py index 03d66d3..df3f76d 100644 --- a/avito/auth/__init__.py +++ b/avito/auth/__init__.py @@ -1,20 +1,14 @@ """Пакет аутентификации.""" -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", @@ -25,16 +19,3 @@ "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/auth/provider.py b/avito/auth/provider.py index 816d539..2d1edbb 100644 --- a/avito/auth/provider.py +++ b/avito/auth/provider.py @@ -17,7 +17,9 @@ TokenResponse, ) from avito.auth.settings import AuthSettings -from avito.core.exceptions import AuthenticationError +from avito.core.exceptions import AuthenticationError, ConfigurationError + +_UNSET = object() class TokenFetcher(Protocol): @@ -52,13 +54,16 @@ def refresh_access_token(self) -> TokenResponse: """Принудительно обновляет токен через refresh token или client credentials.""" token_response = self._fetch_token_response() - self._store_token_response(token_response) + self._update_tokens( + access_token=token_response.access_token, + refresh_token=token_response.refresh_token, + ) return token_response def invalidate_token(self) -> None: """Сбрасывает закэшированный токен после `401 Unauthorized`.""" - self._access_token = None + self._update_tokens(access_token=None) def close(self) -> None: """Закрывает внутренние HTTP-клиенты provider-а.""" @@ -82,7 +87,7 @@ def get_autoteka_access_token(self) -> str: scope=self.settings.autoteka_scope, ) ) - self._autoteka_access_token = token_response.access_token + self._update_tokens(autoteka_access_token=token_response.access_token) token = token_response.access_token return token.value @@ -128,26 +133,53 @@ def _fetch_token_response(self) -> TokenResponse: ) ) - def _store_token_response(self, token_response: TokenResponse) -> None: - self._access_token = token_response.access_token - self._refresh_token = token_response.refresh_token or self._refresh_token + def _update_tokens( + self, + *, + access_token: AccessToken | None | object = _UNSET, + refresh_token: str | None | object = _UNSET, + autoteka_access_token: AccessToken | None | object = _UNSET, + ) -> None: + if access_token is not _UNSET: + self._access_token = access_token if isinstance(access_token, AccessToken) else None + if refresh_token is not _UNSET: + if isinstance(refresh_token, str): + self._refresh_token = refresh_token + if autoteka_access_token is not _UNSET: + self._autoteka_access_token = ( + autoteka_access_token + if isinstance(autoteka_access_token, AccessToken) + else None + ) def _get_token_client(self) -> TokenClient: if self.token_client is None: self.token_client = TokenClient(self.settings) - return self.token_client + token_client = self.token_client + if token_client is None: + raise ConfigurationError("Не удалось инициализировать OAuth token client.") + return 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 + alternate_token_client = self.alternate_token_client + if alternate_token_client is None: + raise ConfigurationError("Не удалось инициализировать alternate OAuth token client.") + return alternate_token_client def _get_autoteka_token_client(self) -> TokenClient: if self.autoteka_token_client is None: self.autoteka_token_client = TokenClient( - self.settings, token_url=self.settings.autoteka_token_url + self.settings, + token_url=self.settings.autoteka_token_url, ) - return self.autoteka_token_client + autoteka_token_client = self.autoteka_token_client + if autoteka_token_client is None: + raise ConfigurationError( + "Не удалось инициализировать OAuth token client для Автотеки." + ) + return autoteka_token_client def _require_client_id(self) -> str: if self.settings.client_id is None: @@ -160,7 +192,7 @@ def _require_client_secret(self) -> str: return self.settings.client_secret -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class TokenClient: """Служебный клиент для canonical OAuth token endpoint.""" @@ -261,7 +293,7 @@ def _extract_error_code(self, response: httpx.Response) -> str | None: return None -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class AlternateTokenClient: """Служебный клиент для альтернативного token endpoint из swagger.""" diff --git a/avito/autoteka/__init__.py b/avito/autoteka/__init__.py index 786fd3c..ef49b3b 100644 --- a/avito/autoteka/__init__.py +++ b/avito/autoteka/__init__.py @@ -7,6 +7,7 @@ AutotekaValuation, AutotekaVehicle, ) +from avito.autoteka.enums import AutotekaStatus from avito.autoteka.models import ( AutotekaLeadEvent, AutotekaLeadsResult, @@ -51,6 +52,7 @@ "AutotekaReportsResult", "AutotekaScoring", "AutotekaScoringInfo", + "AutotekaStatus", "AutotekaSpecificationInfo", "AutotekaTeaserInfo", "AutotekaValuation", diff --git a/avito/autoteka/client.py b/avito/autoteka/client.py index 9968ace..282f145 100644 --- a/avito/autoteka/client.py +++ b/avito/autoteka/client.py @@ -48,14 +48,14 @@ from avito.core import RequestContext, Transport -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class AutotekaBaseClient: """Базовый клиент Автотеки с отдельным access token.""" transport: Transport def _context(self, operation_name: str, *, allow_retry: bool = False) -> RequestContext: - auth_provider = getattr(self.transport, "_auth_provider", None) + auth_provider = self.transport.auth_provider headers: dict[str, str] = {} if auth_provider is not None: headers["Authorization"] = f"Bearer {auth_provider.get_autoteka_access_token()}" @@ -67,137 +67,173 @@ def _context(self, operation_name: str, *, allow_retry: bool = False) -> Request ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class CatalogClient(AutotekaBaseClient): """Выполняет HTTP-операции автокаталога.""" - def resolve_catalog(self, request: CatalogResolveRequest) -> CatalogResolveResult: - payload = self.transport.request_json( + def resolve_catalog(self, *, brand_id: int) -> CatalogResolveResult: + return self.transport.request_public_model( "POST", "/autoteka/v1/catalogs/resolve", context=self._context("autoteka.catalog.resolve", allow_retry=True), - json_body=request.to_payload(), + mapper=map_catalogs_resolve, + json_body=CatalogResolveRequest(brand_id=brand_id).to_payload(), ) - return map_catalogs_resolve(payload) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class LeadsClient(AutotekaBaseClient): """Выполняет HTTP-операции сервиса Сигнал.""" - def get_leads(self, request: LeadsRequest) -> AutotekaLeadsResult: - payload = self.transport.request_json( + def get_leads(self, *, limit: int) -> AutotekaLeadsResult: + return self.transport.request_public_model( "POST", "/autoteka/v1/get-leads/", context=self._context("autoteka.leads.get", allow_retry=True), - json_body=request.to_payload(), + mapper=map_leads, + json_body=LeadsRequest(limit=limit).to_payload(), ) - return map_leads(payload) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class PreviewClient(AutotekaBaseClient): """Выполняет HTTP-операции превью автомобиля.""" - def create_by_vin(self, request: VinRequest) -> AutotekaPreviewInfo: + def create_by_vin( + self, *, vin: str, idempotency_key: str | None = None + ) -> AutotekaPreviewInfo: return self._post_preview( - "/autoteka/v1/previews", "autoteka.preview.create_by_vin", request + "/autoteka/v1/previews", + "autoteka.preview.create_by_vin", + VinRequest(vin=vin), + idempotency_key=idempotency_key, ) - def create_by_external_item(self, request: ExternalItemPreviewRequest) -> AutotekaPreviewInfo: + def create_by_external_item( + self, + *, + item_id: str, + site: str, + idempotency_key: str | None = None, + ) -> AutotekaPreviewInfo: return self._post_preview( "/autoteka/v1/request-preview-by-external-item", "autoteka.preview.create_by_external_item", - request, + ExternalItemPreviewRequest(item_id=item_id, site=site), + idempotency_key=idempotency_key, ) - def create_by_item_id(self, request: ItemIdRequest) -> AutotekaPreviewInfo: + def create_by_item_id( + self, *, item_id: int, idempotency_key: str | None = None + ) -> AutotekaPreviewInfo: return self._post_preview( "/autoteka/v1/request-preview-by-item-id", "autoteka.preview.create_by_item_id", - request, + ItemIdRequest(item_id=item_id), + idempotency_key=idempotency_key, ) - def create_by_reg_number(self, request: RegNumberRequest) -> AutotekaPreviewInfo: + def create_by_reg_number( + self, *, reg_number: str, idempotency_key: str | None = None + ) -> AutotekaPreviewInfo: return self._post_preview( "/autoteka/v1/request-preview-by-regnumber", "autoteka.preview.create_by_reg_number", - request, + RegNumberRequest(reg_number=reg_number), + idempotency_key=idempotency_key, ) def get_preview(self, *, preview_id: int | str) -> AutotekaPreviewInfo: - payload = self.transport.request_json( + return self.transport.request_public_model( "GET", f"/autoteka/v1/previews/{preview_id}", context=self._context("autoteka.preview.get"), + mapper=map_preview, ) - return map_preview(payload) def _post_preview( self, path: str, operation: str, request: VinRequest | ExternalItemPreviewRequest | ItemIdRequest | RegNumberRequest, + idempotency_key: str | None = None, ) -> AutotekaPreviewInfo: - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", path, - context=self._context(operation, allow_retry=True), + context=self._context(operation, allow_retry=idempotency_key is not None), + mapper=map_preview, json_body=request.to_payload(), + idempotency_key=idempotency_key, ) - return map_preview(payload) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class ReportClient(AutotekaBaseClient): """Выполняет HTTP-операции отчетов Автотеки.""" def get_active_package(self) -> AutotekaPackageInfo: - payload = self.transport.request_json( + return self.transport.request_public_model( "GET", "/autoteka/v1/packages/active_package", context=self._context("autoteka.report.get_active_package"), + mapper=map_package, ) - return map_package(payload) - def create_report(self, request: PreviewReportRequest) -> AutotekaReportInfo: - return self._post_report("/autoteka/v1/reports", "autoteka.report.create", request) + def create_report( + self, *, preview_id: int, idempotency_key: str | None = None + ) -> AutotekaReportInfo: + return self._post_report( + "/autoteka/v1/reports", + "autoteka.report.create", + PreviewReportRequest(preview_id=preview_id), + idempotency_key=idempotency_key, + ) - def create_report_by_vehicle_id(self, request: VehicleIdRequest) -> AutotekaReportInfo: + def create_report_by_vehicle_id( + self, *, vehicle_id: str, idempotency_key: str | None = None + ) -> AutotekaReportInfo: return self._post_report( "/autoteka/v1/reports-by-vehicle-id", "autoteka.report.create_by_vehicle_id", - request, + VehicleIdRequest(vehicle_id=vehicle_id), + idempotency_key=idempotency_key, ) def list_reports(self) -> AutotekaReportsResult: - payload = self.transport.request_json( + return self.transport.request_public_model( "GET", "/autoteka/v1/reports/list/", context=self._context("autoteka.report.list"), + mapper=map_reports, ) - return map_reports(payload) def get_report(self, *, report_id: int | str) -> AutotekaReportInfo: - payload = self.transport.request_json( + return self.transport.request_public_model( "GET", f"/autoteka/v1/reports/{report_id}", context=self._context("autoteka.report.get"), + mapper=map_report, ) - return map_report(payload) - def create_sync_report_by_reg_number(self, request: RegNumberRequest) -> AutotekaReportInfo: + def create_sync_report_by_reg_number( + self, *, reg_number: str, idempotency_key: str | None = None + ) -> AutotekaReportInfo: return self._post_report( "/autoteka/v1/sync/create-by-regnumber", "autoteka.report.create_sync_by_reg_number", - request, + RegNumberRequest(reg_number=reg_number), + idempotency_key=idempotency_key, ) - def create_sync_report_by_vin(self, request: VinRequest) -> AutotekaReportInfo: + def create_sync_report_by_vin( + self, *, vin: str, idempotency_key: str | None = None + ) -> AutotekaReportInfo: return self._post_report( "/autoteka/v1/sync/create-by-vin", "autoteka.report.create_sync_by_vin", - request, + VinRequest(vin=vin), + idempotency_key=idempotency_key, ) def _post_report( @@ -205,164 +241,195 @@ def _post_report( path: str, operation: str, request: PreviewReportRequest | VehicleIdRequest | RegNumberRequest | VinRequest, + idempotency_key: str | None = None, ) -> AutotekaReportInfo: - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", path, - context=self._context(operation, allow_retry=True), + context=self._context(operation, allow_retry=idempotency_key is not None), + mapper=map_report, json_body=request.to_payload(), + idempotency_key=idempotency_key, ) - return map_report(payload) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class MonitoringClient(AutotekaBaseClient): """Выполняет HTTP-операции мониторинга.""" - def add_bucket(self, request: MonitoringBucketRequest) -> MonitoringBucketResult: + def add_bucket( + self, *, vehicles: list[str], idempotency_key: str | None = None + ) -> MonitoringBucketResult: return self._post_bucket( "/autoteka/v1/monitoring/bucket/add", "autoteka.monitoring.bucket_add", - request, + MonitoringBucketRequest(vehicles=vehicles), + idempotency_key=idempotency_key, ) - def delete_bucket(self) -> MonitoringBucketResult: - payload = self.transport.request_json( + def delete_bucket(self, *, idempotency_key: str | None = None) -> MonitoringBucketResult: + return self.transport.request_public_model( "POST", "/autoteka/v1/monitoring/bucket/delete", - context=self._context("autoteka.monitoring.bucket_delete", allow_retry=True), + context=self._context( + "autoteka.monitoring.bucket_delete", + allow_retry=idempotency_key is not None, + ), + mapper=map_monitoring_bucket, + idempotency_key=idempotency_key, ) - return map_monitoring_bucket(payload) - def remove_bucket(self, request: MonitoringBucketRequest) -> MonitoringBucketResult: + def remove_bucket( + self, *, vehicles: list[str], idempotency_key: str | None = None + ) -> MonitoringBucketResult: return self._post_bucket( "/autoteka/v1/monitoring/bucket/remove", "autoteka.monitoring.bucket_remove", - request, + MonitoringBucketRequest(vehicles=vehicles), + idempotency_key=idempotency_key, ) def get_reg_actions( self, *, query: MonitoringEventsQuery | None = None ) -> MonitoringEventsResult: - payload = self.transport.request_json( + return self.transport.request_public_model( "GET", "/autoteka/v1/monitoring/get-reg-actions/", context=self._context("autoteka.monitoring.get_reg_actions"), + mapper=map_monitoring_events, 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: MonitoringBucketRequest, + idempotency_key: str | None = None, ) -> MonitoringBucketResult: - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", path, - context=self._context(operation, allow_retry=True), + context=self._context(operation, allow_retry=idempotency_key is not None), + mapper=map_monitoring_bucket, json_body=request.to_payload(), + idempotency_key=idempotency_key, ) - return map_monitoring_bucket(payload) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class ScoringClient(AutotekaBaseClient): """Выполняет HTTP-операции скоринга рисков.""" - def create_by_vehicle_id(self, request: VehicleIdRequest) -> AutotekaScoringInfo: - payload = self.transport.request_json( + def create_by_vehicle_id( + self, *, vehicle_id: str, idempotency_key: str | None = None + ) -> AutotekaScoringInfo: + return self.transport.request_public_model( "POST", "/autoteka/v1/scoring/by-vehicle-id", - context=self._context("autoteka.scoring.create_by_vehicle_id", allow_retry=True), - json_body=request.to_payload(), + context=self._context( + "autoteka.scoring.create_by_vehicle_id", + allow_retry=idempotency_key is not None, + ), + mapper=map_scoring, + json_body=VehicleIdRequest(vehicle_id=vehicle_id).to_payload(), + idempotency_key=idempotency_key, ) - return map_scoring(payload) def get_by_id(self, *, scoring_id: int | str) -> AutotekaScoringInfo: - payload = self.transport.request_json( + return self.transport.request_public_model( "GET", f"/autoteka/v1/scoring/{scoring_id}", context=self._context("autoteka.scoring.get_by_id"), + mapper=map_scoring, ) - return map_scoring(payload) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class SpecificationsClient(AutotekaBaseClient): """Выполняет HTTP-операции спецификаций автомобиля.""" - def create_by_plate_number(self, request: PlateNumberRequest) -> AutotekaSpecificationInfo: + def create_by_plate_number( + self, *, plate_number: str, idempotency_key: str | None = None + ) -> AutotekaSpecificationInfo: return self._post_specification( "/autoteka/v1/specifications/by-plate-number", "autoteka.specification.create_by_plate_number", - request, + PlateNumberRequest(plate_number=plate_number), + idempotency_key=idempotency_key, ) - def create_by_vehicle_id(self, request: VehicleIdRequest) -> AutotekaSpecificationInfo: + def create_by_vehicle_id( + self, *, vehicle_id: str, idempotency_key: str | None = None + ) -> AutotekaSpecificationInfo: return self._post_specification( "/autoteka/v1/specifications/by-vehicle-id", "autoteka.specification.create_by_vehicle_id", - request, + VehicleIdRequest(vehicle_id=vehicle_id), + idempotency_key=idempotency_key, ) def get_by_id(self, *, specification_id: int | str) -> AutotekaSpecificationInfo: - payload = self.transport.request_json( + return self.transport.request_public_model( "GET", f"/autoteka/v1/specifications/specification/{specification_id}", context=self._context("autoteka.specification.get_by_id"), + mapper=map_specification, ) - return map_specification(payload) def _post_specification( self, path: str, operation: str, request: PlateNumberRequest | VehicleIdRequest, + idempotency_key: str | None = None, ) -> AutotekaSpecificationInfo: - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", path, - context=self._context(operation, allow_retry=True), + context=self._context(operation, allow_retry=idempotency_key is not None), + mapper=map_specification, json_body=request.to_payload(), + idempotency_key=idempotency_key, ) - return map_specification(payload) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class TeaserClient(AutotekaBaseClient): """Выполняет HTTP-операции тизеров.""" - def create(self, request: TeaserCreateRequest) -> AutotekaTeaserInfo: - payload = self.transport.request_json( + def create( + self, *, vehicle_id: str, idempotency_key: str | None = None + ) -> AutotekaTeaserInfo: + return self.transport.request_public_model( "POST", "/autoteka/v1/teasers", - context=self._context("autoteka.teaser.create", allow_retry=True), - json_body=request.to_payload(), + context=self._context("autoteka.teaser.create", allow_retry=idempotency_key is not None), + mapper=map_teaser, + json_body=TeaserCreateRequest(vehicle_id=vehicle_id).to_payload(), + idempotency_key=idempotency_key, ) - return map_teaser(payload) def get(self, *, teaser_id: int | str) -> AutotekaTeaserInfo: - payload = self.transport.request_json( + return self.transport.request_public_model( "GET", f"/autoteka/v1/teasers/{teaser_id}", context=self._context("autoteka.teaser.get"), + mapper=map_teaser, ) - return map_teaser(payload) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class ValuationClient(AutotekaBaseClient): """Выполняет HTTP-операции оценки стоимости.""" def get_by_specification( self, request: ValuationBySpecificationRequest ) -> AutotekaValuationInfo: - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", "/autoteka/v1/valuation/by-specification", context=self._context("autoteka.valuation.by_specification", allow_retry=True), + mapper=map_valuation, json_body=request.to_payload(), ) - return map_valuation(payload) diff --git a/avito/autoteka/domain.py b/avito/autoteka/domain.py index e2fb0e8..0d87868 100644 --- a/avito/autoteka/domain.py +++ b/avito/autoteka/domain.py @@ -25,22 +25,11 @@ AutotekaSpecificationInfo, AutotekaTeaserInfo, AutotekaValuationInfo, - CatalogResolveRequest, CatalogResolveResult, - ExternalItemPreviewRequest, - ItemIdRequest, - LeadsRequest, - MonitoringBucketRequest, MonitoringBucketResult, MonitoringEventsQuery, MonitoringEventsResult, - PlateNumberRequest, - PreviewReportRequest, - RegNumberRequest, - TeaserCreateRequest, ValuationBySpecificationRequest, - VehicleIdRequest, - VinRequest, ) from avito.core import ValidationError from avito.core.domain import DomainObject @@ -56,44 +45,67 @@ class AutotekaVehicle(DomainObject): def resolve_catalog(self, *, brand_id: int) -> CatalogResolveResult: """Актуализирует параметры автокаталога.""" - return CatalogClient(self.transport).resolve_catalog(CatalogResolveRequest(brand_id=brand_id)) + return CatalogClient(self.transport).resolve_catalog(brand_id=brand_id) def get_leads(self, *, limit: int) -> AutotekaLeadsResult: - return LeadsClient(self.transport).get_leads(LeadsRequest(limit=limit)) - - def create_preview_by_vin(self, *, vin: str) -> AutotekaPreviewInfo: - return PreviewClient(self.transport).create_by_vin(VinRequest(vin=vin)) + return LeadsClient(self.transport).get_leads(limit=limit) + + def create_preview_by_vin( + self, *, vin: str, idempotency_key: str | None = None + ) -> AutotekaPreviewInfo: + return PreviewClient(self.transport).create_by_vin( + vin=vin, + idempotency_key=idempotency_key, + ) 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, *, item_id: str, site: str) -> AutotekaPreviewInfo: + def create_preview_by_external_item( + self, + *, + item_id: str, + site: str, + idempotency_key: str | None = None, + ) -> AutotekaPreviewInfo: return PreviewClient(self.transport).create_by_external_item( - ExternalItemPreviewRequest(item_id=item_id, site=site) + item_id=item_id, + site=site, + idempotency_key=idempotency_key, ) - 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_item_id( + self, *, item_id: int, idempotency_key: str | None = None + ) -> AutotekaPreviewInfo: + return PreviewClient(self.transport).create_by_item_id( + item_id=item_id, + idempotency_key=idempotency_key, + ) - def create_preview_by_reg_number(self, *, reg_number: str) -> AutotekaPreviewInfo: + def create_preview_by_reg_number( + self, *, reg_number: str, idempotency_key: str | None = None + ) -> AutotekaPreviewInfo: return PreviewClient(self.transport).create_by_reg_number( - RegNumberRequest(reg_number=reg_number) + reg_number=reg_number, + idempotency_key=idempotency_key, ) def create_specification_by_plate_number( - self, *, plate_number: str + self, *, plate_number: str, idempotency_key: str | None = None ) -> AutotekaSpecificationInfo: return SpecificationsClient(self.transport).create_by_plate_number( - PlateNumberRequest(plate_number=plate_number) + plate_number=plate_number, + idempotency_key=idempotency_key, ) def create_specification_by_vehicle_id( - self, *, vehicle_id: str + self, *, vehicle_id: str, idempotency_key: str | None = None ) -> AutotekaSpecificationInfo: return SpecificationsClient(self.transport).create_by_vehicle_id( - VehicleIdRequest(vehicle_id=vehicle_id) + vehicle_id=vehicle_id, + idempotency_key=idempotency_key, ) def get_specification_by_id( @@ -105,8 +117,13 @@ def get_specification_by_id( specification_id=specification_id or self._require_vehicle_id("specification_id") ) - def create_teaser(self, *, vehicle_id: str) -> AutotekaTeaserInfo: - return TeaserClient(self.transport).create(TeaserCreateRequest(vehicle_id=vehicle_id)) + def create_teaser( + self, *, vehicle_id: str, idempotency_key: str | None = None + ) -> AutotekaTeaserInfo: + return TeaserClient(self.transport).create( + vehicle_id=vehicle_id, + idempotency_key=idempotency_key, + ) def get_teaser(self, *, teaser_id: int | str | None = None) -> AutotekaTeaserInfo: return TeaserClient(self.transport).get( @@ -129,12 +146,20 @@ class AutotekaReport(DomainObject): def get_active_package(self) -> AutotekaPackageInfo: return ReportClient(self.transport).get_active_package() - def create_report(self, *, preview_id: int) -> AutotekaReportInfo: - return ReportClient(self.transport).create_report(PreviewReportRequest(preview_id=preview_id)) + def create_report( + self, *, preview_id: int, idempotency_key: str | None = None + ) -> AutotekaReportInfo: + return ReportClient(self.transport).create_report( + preview_id=preview_id, + idempotency_key=idempotency_key, + ) - def create_report_by_vehicle_id(self, *, vehicle_id: str) -> AutotekaReportInfo: + def create_report_by_vehicle_id( + self, *, vehicle_id: str, idempotency_key: str | None = None + ) -> AutotekaReportInfo: return ReportClient(self.transport).create_report_by_vehicle_id( - VehicleIdRequest(vehicle_id=vehicle_id) + vehicle_id=vehicle_id, + idempotency_key=idempotency_key, ) def list_reports(self) -> AutotekaReportsResult: @@ -147,13 +172,21 @@ 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, *, reg_number: str) -> AutotekaReportInfo: + def create_sync_report_by_reg_number( + self, *, reg_number: str, idempotency_key: str | None = None + ) -> AutotekaReportInfo: return ReportClient(self.transport).create_sync_report_by_reg_number( - RegNumberRequest(reg_number=reg_number) + reg_number=reg_number, + idempotency_key=idempotency_key, ) - def create_sync_report_by_vin(self, *, vin: str) -> AutotekaReportInfo: - return ReportClient(self.transport).create_sync_report_by_vin(VinRequest(vin=vin)) + def create_sync_report_by_vin( + self, *, vin: str, idempotency_key: str | None = None + ) -> AutotekaReportInfo: + return ReportClient(self.transport).create_sync_report_by_vin( + vin=vin, + idempotency_key=idempotency_key, + ) def _require_report_id(self) -> str: if self.report_id is None: @@ -167,21 +200,27 @@ class AutotekaMonitoring(DomainObject): user_id: int | str | None = None - def create_monitoring_bucket_add(self, *, vehicles: list[str]) -> MonitoringBucketResult: + def create_monitoring_bucket_add( + self, *, vehicles: list[str], idempotency_key: str | None = None + ) -> MonitoringBucketResult: return MonitoringClient(self.transport).add_bucket( - MonitoringBucketRequest(vehicles=vehicles) + vehicles=vehicles, + idempotency_key=idempotency_key, ) - def delete_bucket(self) -> MonitoringBucketResult: + def delete_bucket(self, *, idempotency_key: str | None = None) -> MonitoringBucketResult: """Очищает bucket мониторинга.""" - return MonitoringClient(self.transport).delete_bucket() + return MonitoringClient(self.transport).delete_bucket(idempotency_key=idempotency_key) - def remove_bucket(self, *, vehicles: list[str]) -> MonitoringBucketResult: + def remove_bucket( + self, *, vehicles: list[str], idempotency_key: str | None = None + ) -> MonitoringBucketResult: """Удаляет автомобили из bucket мониторинга.""" return MonitoringClient(self.transport).remove_bucket( - MonitoringBucketRequest(vehicles=vehicles) + vehicles=vehicles, + idempotency_key=idempotency_key, ) def get_monitoring_reg_actions( @@ -199,9 +238,12 @@ class AutotekaScoring(DomainObject): scoring_id: int | str | None = None user_id: int | str | None = None - def create_scoring_by_vehicle_id(self, *, vehicle_id: str) -> AutotekaScoringInfo: + def create_scoring_by_vehicle_id( + self, *, vehicle_id: str, idempotency_key: str | None = None + ) -> AutotekaScoringInfo: return ScoringClient(self.transport).create_by_vehicle_id( - VehicleIdRequest(vehicle_id=vehicle_id) + vehicle_id=vehicle_id, + idempotency_key=idempotency_key, ) def get_scoring_by_id(self, *, scoring_id: int | str | None = None) -> AutotekaScoringInfo: diff --git a/avito/autoteka/enums.py b/avito/autoteka/enums.py new file mode 100644 index 0000000..53fa7dc --- /dev/null +++ b/avito/autoteka/enums.py @@ -0,0 +1,16 @@ +"""Enum-значения раздела autoteka.""" + +from __future__ import annotations + +from enum import Enum + + +class AutotekaStatus(str, Enum): + """Статус сущности Автотеки.""" + + UNKNOWN = "__unknown__" + PROCESSING = "processing" + SUCCESS = "success" + + +__all__ = ("AutotekaStatus",) diff --git a/avito/autoteka/mappers.py b/avito/autoteka/mappers.py index 6f1c90d..243dd00 100644 --- a/avito/autoteka/mappers.py +++ b/avito/autoteka/mappers.py @@ -5,6 +5,7 @@ from collections.abc import Mapping from typing import cast +from avito.autoteka.enums import AutotekaStatus from avito.autoteka.models import ( AutotekaLeadEvent, AutotekaLeadsResult, @@ -24,6 +25,7 @@ MonitoringEventsResult, MonitoringInvalidVehicle, ) +from avito.core.enums import map_enum_or_unknown from avito.core.exceptions import ResponseMappingError Payload = Mapping[str, object] @@ -187,7 +189,11 @@ def map_package(payload: object) -> AutotekaPackageInfo: def _map_preview_source(source: Payload) -> AutotekaPreviewInfo: return AutotekaPreviewInfo( preview_id=_str(source, "previewId"), - status=_str(source, "status"), + status=map_enum_or_unknown( + _str(source, "status"), + AutotekaStatus, + enum_name="autoteka.status", + ), vehicle_id=_str(source, "vin", "vehicleId"), reg_number=_str(source, "regNumber", "plateNumber"), ) @@ -207,7 +213,11 @@ def _map_report_source(source: Payload) -> AutotekaReportInfo: data = _mapping(source, "data") return AutotekaReportInfo( report_id=_str(source, "reportId"), - status=_str(source, "status"), + status=map_enum_or_unknown( + _str(source, "status"), + AutotekaStatus, + enum_name="autoteka.status", + ), vehicle_id=_str(data, "vin", "vehicleId") or _str(source, "vin"), created_at=_str(source, "createdAt") or _str(data, "createdAt"), web_link=_str(source, "webLink"), @@ -257,7 +267,11 @@ def map_specification(payload: object) -> AutotekaSpecificationInfo: source = specification or result or data return AutotekaSpecificationInfo( specification_id=_str(source, "specificationId"), - status=_str(source, "status"), + status=map_enum_or_unknown( + _str(source, "status"), + AutotekaStatus, + enum_name="autoteka.status", + ), vehicle_id=_str(source, "vehicleId"), plate_number=_str(source, "plateNumber"), ) @@ -273,7 +287,11 @@ def map_teaser(payload: object) -> AutotekaTeaserInfo: source = teaser_wrapper or result or data return AutotekaTeaserInfo( teaser_id=_str(source, "teaserId"), - status=_str(source, "status"), + status=map_enum_or_unknown( + _str(source, "status"), + AutotekaStatus, + enum_name="autoteka.status", + ), 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"), @@ -288,7 +306,11 @@ def map_valuation(payload: object) -> AutotekaValuationInfo: valuation = _mapping(result, "valuation") source = result or data return AutotekaValuationInfo( - status=_str(source, "status"), + status=map_enum_or_unknown( + _str(source, "status"), + AutotekaStatus, + enum_name="autoteka.status", + ), vehicle_id=_str(source, "vehicleId"), brand=_str(source, "brand"), model=_str(source, "model"), diff --git a/avito/autoteka/models.py b/avito/autoteka/models.py index 483b957..25b5e93 100644 --- a/avito/autoteka/models.py +++ b/avito/autoteka/models.py @@ -4,6 +4,7 @@ from dataclasses import dataclass +from avito.autoteka.enums import AutotekaStatus from avito.core.serialization import SerializableModel @@ -272,7 +273,7 @@ class AutotekaPreviewInfo(SerializableModel): """Информация о превью автомобиля.""" preview_id: str | None - status: str | None + status: AutotekaStatus | None vehicle_id: str | None reg_number: str | None @@ -282,7 +283,7 @@ class AutotekaReportInfo(SerializableModel): """Информация об отчете Автотеки.""" report_id: str | None - status: str | None + status: AutotekaStatus | None vehicle_id: str | None created_at: str | None web_link: str | None @@ -310,7 +311,7 @@ class AutotekaSpecificationInfo(SerializableModel): """Информация о запросе спецификации автомобиля.""" specification_id: str | None - status: str | None + status: AutotekaStatus | None vehicle_id: str | None plate_number: str | None @@ -320,7 +321,7 @@ class AutotekaTeaserInfo(SerializableModel): """Информация о тизере Автотеки.""" teaser_id: str | None - status: str | None + status: AutotekaStatus | None brand: str | None = None model: str | None = None year: int | None = None @@ -330,7 +331,7 @@ class AutotekaTeaserInfo(SerializableModel): class AutotekaValuationInfo(SerializableModel): """Оценка стоимости автомобиля.""" - status: str | None + status: AutotekaStatus | None vehicle_id: str | None brand: str | None model: str | None diff --git a/avito/client.py b/avito/client.py index 4807762..9f4a6e6 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.exceptions import ConfigurationError 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 @@ -62,6 +63,7 @@ def __init__( auth = AuthSettings(client_id=client_id, client_secret=client_secret) settings = AvitoSettings(auth=auth) + self._closed = False 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) @@ -75,18 +77,22 @@ def from_env(cls, *, env_file: str | Path | None = ".env") -> AvitoClient: def auth(self) -> AuthProvider: """Возвращает объект аутентификации и token-flow операций.""" + self._ensure_open() return self.auth_provider def debug_info(self) -> TransportDebugInfo: """Возвращает безопасный снимок transport-настроек для диагностики.""" - return self.transport.debug_info() + return self._require_transport().debug_info() def close(self) -> None: """Закрывает внутренние HTTP-клиенты SDK.""" + if self._closed: + return self.transport.close() self.auth_provider.close() + self._closed = True def __enter__(self) -> AvitoClient: """Открывает клиент как контекстный менеджер.""" @@ -130,54 +136,62 @@ def _build_auth_provider(self) -> AuthProvider: ), ) + def _ensure_open(self) -> None: + if self._closed: + raise ConfigurationError("Клиент закрыт; создайте новый AvitoClient.") + + def _require_transport(self) -> Transport: + self._ensure_open() + return self.transport + def account(self, user_id: int | str | None = None) -> Account: """Создает доменный объект аккаунта.""" - return Account(self.transport, user_id=user_id) + return Account(self._require_transport(), user_id=user_id) def account_hierarchy(self, user_id: int | str | None = None) -> AccountHierarchy: """Создает доменный объект иерархии аккаунта.""" - return AccountHierarchy(self.transport, user_id=user_id) + return AccountHierarchy(self._require_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, item_id=item_id, user_id=user_id) + return Ad(self._require_transport(), item_id=item_id, user_id=user_id) def ad_stats( self, item_id: int | str | None = None, user_id: int | str | None = None ) -> AdStats: """Создает доменный объект статистики объявления.""" - return AdStats(self.transport, item_id=item_id, user_id=user_id) + return AdStats(self._require_transport(), item_id=item_id, user_id=user_id) def ad_promotion( self, item_id: int | str | None = None, user_id: int | str | None = None ) -> AdPromotion: """Создает доменный объект продвижения объявления.""" - return AdPromotion(self.transport, item_id=item_id, user_id=user_id) + return AdPromotion(self._require_transport(), item_id=item_id, user_id=user_id) def autoload_profile(self, user_id: int | str | None = None) -> AutoloadProfile: """Создает доменный объект профиля автозагрузки.""" - return AutoloadProfile(self.transport, user_id=user_id) + return AutoloadProfile(self._require_transport(), user_id=user_id) def autoload_report(self, report_id: int | str | None = None) -> AutoloadReport: """Создает доменный объект отчета автозагрузки.""" - return AutoloadReport(self.transport, report_id=report_id) + return AutoloadReport(self._require_transport(), report_id=report_id) def autoload_archive(self, report_id: int | str | None = None) -> AutoloadArchive: """Создает доменный объект архивных операций автозагрузки.""" - return AutoloadArchive(self.transport, report_id=report_id) + return AutoloadArchive(self._require_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, chat_id=chat_id, user_id=user_id) + return Chat(self._require_transport(), chat_id=chat_id, user_id=user_id) def chat_message( self, @@ -188,157 +202,162 @@ def chat_message( ) -> ChatMessage: """Создает доменный объект сообщения чата.""" - return ChatMessage(self.transport, chat_id=chat_id, message_id=message_id, user_id=user_id) + return ChatMessage( + self._require_transport(), + chat_id=chat_id, + message_id=message_id, + user_id=user_id, + ) def chat_webhook(self) -> ChatWebhook: """Создает доменный объект webhook мессенджера.""" - return ChatWebhook(self.transport) + return ChatWebhook(self._require_transport()) def chat_media(self, *, user_id: int | str | None = None) -> ChatMedia: """Создает доменный объект медиа мессенджера.""" - return ChatMedia(self.transport, user_id=user_id) + return ChatMedia(self._require_transport(), user_id=user_id) def special_offer_campaign(self, campaign_id: int | str | None = None) -> SpecialOfferCampaign: """Создает доменный объект рассылки спецпредложений.""" - return SpecialOfferCampaign(self.transport, campaign_id=campaign_id) + return SpecialOfferCampaign(self._require_transport(), campaign_id=campaign_id) def promotion_order(self, order_id: int | str | None = None) -> PromotionOrder: """Создает доменный объект заявки на продвижение.""" - return PromotionOrder(self.transport, order_id=order_id) + return PromotionOrder(self._require_transport(), order_id=order_id) def bbip_promotion(self, item_id: int | str | None = None) -> BbipPromotion: """Создает доменный объект BBIP-продвижения.""" - return BbipPromotion(self.transport, item_id=item_id) + return BbipPromotion(self._require_transport(), item_id=item_id) def trx_promotion(self, item_id: int | str | None = None) -> TrxPromotion: """Создает доменный объект TrxPromo.""" - return TrxPromotion(self.transport, item_id=item_id) + return TrxPromotion(self._require_transport(), item_id=item_id) def cpa_auction(self, item_id: int | str | None = None) -> CpaAuction: """Создает доменный объект CPA-аукциона.""" - return CpaAuction(self.transport, item_id=item_id) + return CpaAuction(self._require_transport(), item_id=item_id) def target_action_pricing(self, item_id: int | str | None = None) -> TargetActionPricing: """Создает доменный объект цены целевого действия.""" - return TargetActionPricing(self.transport, item_id=item_id) + return TargetActionPricing(self._require_transport(), item_id=item_id) def autostrategy_campaign(self, campaign_id: int | str | None = None) -> AutostrategyCampaign: """Создает доменный объект автостратегии.""" - return AutostrategyCampaign(self.transport, campaign_id=campaign_id) + return AutostrategyCampaign(self._require_transport(), campaign_id=campaign_id) def order(self) -> Order: """Создает доменный объект заказа.""" - return Order(self.transport) + return Order(self._require_transport()) def order_label(self, task_id: int | str | None = None) -> OrderLabel: """Создает доменный объект этикетки заказа.""" - return OrderLabel(self.transport, task_id=task_id) + return OrderLabel(self._require_transport(), task_id=task_id) def delivery_order(self) -> DeliveryOrder: """Создает доменный объект доставки.""" - return DeliveryOrder(self.transport) + return DeliveryOrder(self._require_transport()) def sandbox_delivery(self) -> SandboxDelivery: """Создает доменный объект песочницы доставки.""" - return SandboxDelivery(self.transport) + return SandboxDelivery(self._require_transport()) def delivery_task(self, task_id: int | str | None = None) -> DeliveryTask: """Создает доменный объект задачи доставки.""" - return DeliveryTask(self.transport, task_id=task_id) + return DeliveryTask(self._require_transport(), task_id=task_id) def stock(self) -> Stock: """Создает доменный объект остатков.""" - return Stock(self.transport) + return Stock(self._require_transport()) def vacancy(self, vacancy_id: int | str | None = None) -> Vacancy: """Создает доменный объект вакансии.""" - return Vacancy(self.transport, vacancy_id=vacancy_id) + return Vacancy(self._require_transport(), vacancy_id=vacancy_id) def application(self) -> Application: """Создает доменный объект отклика.""" - return Application(self.transport) + return Application(self._require_transport()) def resume(self, resume_id: int | str | None = None) -> Resume: """Создает доменный объект резюме.""" - return Resume(self.transport, resume_id=resume_id) + return Resume(self._require_transport(), resume_id=resume_id) def job_webhook(self) -> JobWebhook: """Создает доменный объект webhook раздела Работа.""" - return JobWebhook(self.transport) + return JobWebhook(self._require_transport()) def job_dictionary(self, dictionary_id: int | str | None = None) -> JobDictionary: """Создает доменный объект словаря Работа.""" - return JobDictionary(self.transport, dictionary_id=dictionary_id) + return JobDictionary(self._require_transport(), dictionary_id=dictionary_id) def cpa_lead(self) -> CpaLead: """Создает доменный объект CPA-лида.""" - return CpaLead(self.transport) + return CpaLead(self._require_transport()) def cpa_chat(self, chat_id: int | str | None = None) -> CpaChat: """Создает доменный объект CPA-чата.""" - return CpaChat(self.transport, action_id=chat_id) + return CpaChat(self._require_transport(), action_id=chat_id) def cpa_call(self) -> CpaCall: """Создает доменный объект CPA-звонка.""" - return CpaCall(self.transport) + return CpaCall(self._require_transport()) def cpa_archive(self, call_id: int | str | None = None) -> CpaArchive: """Создает доменный объект архивных операций CPA.""" - return CpaArchive(self.transport, call_id=call_id) + return CpaArchive(self._require_transport(), call_id=call_id) def call_tracking_call(self, call_id: int | str | None = None) -> CallTrackingCall: """Создает доменный объект CallTracking.""" - return CallTrackingCall(self.transport, call_id=call_id) + return CallTrackingCall(self._require_transport(), call_id=call_id) def autoteka_vehicle(self, vehicle_id: int | str | None = None) -> AutotekaVehicle: """Создает доменный объект транспортного средства Автотеки.""" - return AutotekaVehicle(self.transport, vehicle_id=vehicle_id) + return AutotekaVehicle(self._require_transport(), vehicle_id=vehicle_id) def autoteka_report(self, report_id: int | str | None = None) -> AutotekaReport: """Создает доменный объект отчета Автотеки.""" - return AutotekaReport(self.transport, report_id=report_id) + return AutotekaReport(self._require_transport(), report_id=report_id) def autoteka_monitoring(self) -> AutotekaMonitoring: """Создает доменный объект мониторинга Автотеки.""" - return AutotekaMonitoring(self.transport) + return AutotekaMonitoring(self._require_transport()) def autoteka_scoring(self, scoring_id: int | str | None = None) -> AutotekaScoring: """Создает доменный объект скоринга Автотеки.""" - return AutotekaScoring(self.transport, scoring_id=scoring_id) + return AutotekaScoring(self._require_transport(), scoring_id=scoring_id) def autoteka_valuation(self) -> AutotekaValuation: """Создает доменный объект оценки Автотеки.""" - return AutotekaValuation(self.transport) + return AutotekaValuation(self._require_transport()) def realty_listing( self, @@ -348,7 +367,7 @@ def realty_listing( ) -> RealtyListing: """Создает доменный объект объявления недвижимости.""" - return RealtyListing(self.transport, item_id=item_id, user_id=user_id) + return RealtyListing(self._require_transport(), item_id=item_id, user_id=user_id) def realty_booking( self, @@ -358,7 +377,7 @@ def realty_booking( ) -> RealtyBooking: """Создает доменный объект бронирования недвижимости.""" - return RealtyBooking(self.transport, item_id=item_id, user_id=user_id) + return RealtyBooking(self._require_transport(), item_id=item_id, user_id=user_id) def realty_pricing( self, @@ -368,7 +387,7 @@ def realty_pricing( ) -> RealtyPricing: """Создает доменный объект цен недвижимости.""" - return RealtyPricing(self.transport, item_id=item_id, user_id=user_id) + return RealtyPricing(self._require_transport(), item_id=item_id, user_id=user_id) def realty_analytics_report( self, @@ -378,27 +397,27 @@ def realty_analytics_report( ) -> RealtyAnalyticsReport: """Создает доменный объект аналитического отчета недвижимости.""" - return RealtyAnalyticsReport(self.transport, item_id=item_id, user_id=user_id) + return RealtyAnalyticsReport(self._require_transport(), item_id=item_id, user_id=user_id) def review(self) -> Review: """Создает доменный объект отзыва.""" - return Review(self.transport) + return Review(self._require_transport()) def review_answer(self, answer_id: int | str | None = None) -> ReviewAnswer: """Создает доменный объект ответа на отзыв.""" - return ReviewAnswer(self.transport, answer_id=answer_id) + return ReviewAnswer(self._require_transport(), answer_id=answer_id) def rating_profile(self) -> RatingProfile: """Создает доменный объект рейтингового профиля.""" - return RatingProfile(self.transport) + return RatingProfile(self._require_transport()) def tariff(self, tariff_id: int | str | None = None) -> Tariff: """Создает доменный объект тарифа.""" - return Tariff(self.transport, tariff_id=tariff_id) + return Tariff(self._require_transport(), tariff_id=tariff_id) __all__ = ("AvitoClient",) diff --git a/avito/config.py b/avito/config.py index 551b2ae..85251ee 100644 --- a/avito/config.py +++ b/avito/config.py @@ -8,9 +8,21 @@ from avito._env import parse_env_int, resolve_env_aliases from avito.auth.settings import AuthSettings +from avito.core.exceptions import ConfigurationError from avito.core.retries import RetryPolicy from avito.core.types import ApiTimeouts +_FORBIDDEN_USER_AGENT_SUFFIX_FRAGMENTS = ( + "authorization", + "access_token", + "refresh_token", + "token", + "client_secret", + "secret", + "password", + "bearer", +) + @dataclass(slots=True, frozen=True) class AvitoSettings: @@ -19,10 +31,12 @@ class AvitoSettings: ENV_ALIASES: ClassVar[dict[str, tuple[str, ...]]] = { "base_url": ("AVITO_BASE_URL",), "user_id": ("AVITO_USER_ID",), + "user_agent_suffix": ("AVITO_USER_AGENT_SUFFIX",), } base_url: str = "https://api.avito.ru" user_id: int | None = None + user_agent_suffix: str | None = None auth: AuthSettings = field(default_factory=AuthSettings) timeouts: ApiTimeouts = field(default_factory=ApiTimeouts) retry_policy: RetryPolicy = field(default_factory=RetryPolicy) @@ -37,6 +51,7 @@ def from_env(cls, *, env_file: str | Path | None = ".env") -> AvitoSettings: 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, + user_agent_suffix=resolved_values.get("user_agent_suffix"), auth=auth_settings, timeouts=ApiTimeouts.from_env(env_file=env_file), retry_policy=RetryPolicy.from_env(env_file=env_file), @@ -58,8 +73,23 @@ def supported_env_vars(cls) -> dict[str, tuple[str, ...]]: def validate_required(self) -> AvitoSettings: """Проверяет обязательные части публичной конфигурации SDK.""" + self._validate_user_agent_suffix() self.auth.validate_required() return self + def _validate_user_agent_suffix(self) -> None: + suffix = self.user_agent_suffix + if suffix is None: + return + if not suffix.strip(): + raise ConfigurationError("Поле `user_agent_suffix` не должно быть пустым.") + if "\r" in suffix or "\n" in suffix: + raise ConfigurationError("Поле `user_agent_suffix` не должно содержать переводы строки.") + lowered = suffix.lower() + if any(fragment in lowered for fragment in _FORBIDDEN_USER_AGENT_SUFFIX_FRAGMENTS): + raise ConfigurationError( + "Поле `user_agent_suffix` не должно содержать секреты или auth-данные." + ) + __all__ = ("AvitoSettings",) diff --git a/avito/core/__init__.py b/avito/core/__init__.py index c0f7b94..266d548 100644 --- a/avito/core/__init__.py +++ b/avito/core/__init__.py @@ -1,39 +1,33 @@ """Пакет общей инфраструктуры SDK.""" -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, - ) +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", @@ -63,40 +57,3 @@ "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/domain.py b/avito/core/domain.py index 92a8ab1..a3f07ff 100644 --- a/avito/core/domain.py +++ b/avito/core/domain.py @@ -3,8 +3,10 @@ from __future__ import annotations from dataclasses import dataclass +from typing import TYPE_CHECKING -from avito.core.transport import Transport +if TYPE_CHECKING: + from avito.core.transport import Transport @dataclass(slots=True, frozen=True) diff --git a/avito/core/enums.py b/avito/core/enums.py new file mode 100644 index 0000000..828e88b --- /dev/null +++ b/avito/core/enums.py @@ -0,0 +1,30 @@ +"""Общие helper-функции для enum-значений из upstream API.""" + +from __future__ import annotations + +import logging +from enum import Enum + +logger = logging.getLogger(__name__) +_warned_unknown_enum_values: set[tuple[str, str]] = set() + + +def map_enum_or_unknown[T: Enum](value: str | None, enum_type: type[T], *, enum_name: str) -> T | None: + """Преобразует строку в enum с fallback на UNKNOWN и warning один раз на процесс.""" + + if value is None: + return None + try: + return enum_type(value) + except ValueError: + warning_key = (enum_name, value) + if warning_key not in _warned_unknown_enum_values: + _warned_unknown_enum_values.add(warning_key) + logger.warning( + "Получено неизвестное значение enum от upstream.", + extra={"enum": enum_name, "value": value}, + ) + return enum_type.__members__["UNKNOWN"] + + +__all__ = ("map_enum_or_unknown",) diff --git a/avito/core/exceptions.py b/avito/core/exceptions.py index e03848c..c7fd5bf 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 FrozenInstanceError, dataclass, field +from dataclasses import dataclass, field _SECRET_KEYS = ( "authorization", @@ -40,7 +40,7 @@ def sanitize_metadata(value: object) -> object: return value -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class AvitoError(Exception): """Базовое исключение SDK с безопасными диагностическими метаданными.""" @@ -51,21 +51,6 @@ 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"нельзя присвоить значение полю {name!r}") - - object.__setattr__(self, name, value) def __post_init__(self) -> None: sanitized_payload = sanitize_metadata(self.payload) @@ -73,10 +58,10 @@ def __post_init__(self) -> None: sanitize_metadata(dict(self.headers)) if self.headers is not None else None ) sanitized_metadata = sanitize_metadata(dict(self.metadata)) + Exception.__init__(self, self.message) 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/avito/core/mapping.py b/avito/core/mapping.py index 2a6ea2c..7a55490 100644 --- a/avito/core/mapping.py +++ b/avito/core/mapping.py @@ -17,17 +17,19 @@ def request_public_model[ModelT]( mapper: Callable[[object], ModelT], params: Mapping[str, object] | None = None, json_body: Mapping[str, object] | None = None, + idempotency_key: str | None = None, ) -> ModelT: """Выполняет HTTP-запрос и маппит JSON в публичную модель SDK.""" - payload = transport.request_json( + return transport.request_public_model( method, path, context=context, + mapper=mapper, params=params, json_body=json_body, + idempotency_key=idempotency_key, ) - return mapper(payload) __all__ = ("request_public_model",) diff --git a/avito/core/retries.py b/avito/core/retries.py index 11e173d..c3a9333 100644 --- a/avito/core/retries.py +++ b/avito/core/retries.py @@ -2,7 +2,8 @@ from __future__ import annotations -from dataclasses import dataclass +import random as random_module +from dataclasses import dataclass, field from pathlib import Path from typing import ClassVar, Literal @@ -31,6 +32,7 @@ class RetryPolicy: "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_delay": ("AVITO_RETRY_MAX_DELAY",), } max_attempts: int = 3 @@ -40,6 +42,12 @@ class RetryPolicy: retry_on_server_error: bool = True retry_on_transport_error: bool = True max_rate_limit_wait_seconds: float = 30.0 + max_delay: float = 30.0 + random_source: random_module.Random = field( + default_factory=random_module.Random, + repr=False, + compare=False, + ) @classmethod def from_env(cls, *, env_file: str | Path | None = ".env") -> RetryPolicy: @@ -54,15 +62,18 @@ def from_env(cls, *, env_file: str | Path | None = ".env") -> RetryPolicy: 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 + max_delay = defaults.max_delay for field_name, value in resolved_values.items(): if field_name == "max_attempts": max_attempts = parse_env_int(value, field_name=field_name) - elif field_name in {"backoff_factor", "max_rate_limit_wait_seconds"}: + elif field_name in {"backoff_factor", "max_rate_limit_wait_seconds", "max_delay"}: parsed_float = parse_env_float(value, field_name=field_name) if field_name == "backoff_factor": backoff_factor = parsed_float - else: + elif field_name == "max_rate_limit_wait_seconds": max_rate_limit_wait_seconds = parsed_float + else: + max_delay = parsed_float elif field_name == "retryable_methods": retryable_methods = parse_env_str_tuple(value, field_name=field_name) else: @@ -81,6 +92,7 @@ def from_env(cls, *, env_file: str | Path | None = ".env") -> RetryPolicy: 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, + max_delay=max_delay, ) def is_retryable_method(self, method: str, *, explicit_retry: bool = False) -> bool: @@ -92,7 +104,9 @@ def compute_backoff(self, attempt: int) -> float: """Возвращает backoff в секундах для номера попытки, начиная с единицы.""" safe_attempt = max(attempt - 1, 0) - return float(self.backoff_factor) * float(2**safe_attempt) + base_delay = float(self.backoff_factor) * float(2**safe_attempt) + capped_delay = min(base_delay, self.max_delay) + return capped_delay * self.random_source.random() @dataclass(slots=True, frozen=True) diff --git a/avito/core/transport.py b/avito/core/transport.py index c6a109e..340668a 100644 --- a/avito/core/transport.py +++ b/avito/core/transport.py @@ -2,7 +2,9 @@ from __future__ import annotations +import importlib.metadata as importlib_metadata import json +import platform import time from collections.abc import Callable, Mapping, Sequence from email.message import Message @@ -12,7 +14,6 @@ import httpx -from avito.auth.provider import AuthProvider from avito.core.exceptions import ( AuthenticationError, AuthorizationError, @@ -36,6 +37,7 @@ ) if TYPE_CHECKING: + from avito.auth.provider import AuthProvider from avito.config import AvitoSettings QueryScalar = str | int | float | bool | None @@ -82,6 +84,7 @@ def __init__( timeout=build_httpx_timeout(settings.timeouts), ) self._sleep = sleep + self._user_agent = self._build_user_agent() def debug_info(self) -> TransportDebugInfo: """Возвращает безопасный снимок transport-конфигурации без секретов.""" @@ -98,6 +101,12 @@ def debug_info(self) -> TransportDebugInfo: retryable_methods=self._retry_policy.retryable_methods, ) + @property + def auth_provider(self) -> AuthProvider | None: + """Возвращает auth provider transport-слоя, если он настроен.""" + + return self._auth_provider + def close(self) -> None: """Закрывает внутренний экземпляр `httpx.Client`.""" @@ -115,11 +124,16 @@ def request( files: Mapping[str, object] | None = None, headers: Mapping[str, str] | None = None, content: bytes | None = None, + idempotency_key: str | None = None, ) -> httpx.Response: """Выполняет запрос и возвращает успешный `httpx.Response`.""" normalized_path = self._normalize_path(path) - request_headers = self._merge_headers(context=context, headers=headers) + request_headers = self._merge_headers( + context=context, + headers=headers, + idempotency_key=idempotency_key, + ) timeout = build_httpx_timeout(context.timeout or self._settings.timeouts) attempt = 0 unauthorized_refresh_used = False @@ -144,6 +158,7 @@ def request( attempt=attempt, context=context, is_timeout=isinstance(exc, httpx.TimeoutException), + idempotency_key=idempotency_key, ) if decision.should_retry: self._sleep(decision.delay_seconds) @@ -176,6 +191,7 @@ def request( attempt=attempt, context=context, response=response, + idempotency_key=idempotency_key, ) if decision.should_retry: self._sleep(decision.delay_seconds) @@ -188,6 +204,7 @@ def request( attempt=attempt, context=context, response=response, + idempotency_key=idempotency_key, ) if decision.should_retry: self._sleep(decision.delay_seconds) @@ -210,6 +227,7 @@ def request_json( data: Mapping[str, object] | None = None, files: Mapping[str, object] | None = None, headers: Mapping[str, str] | None = None, + idempotency_key: str | None = None, ) -> object: """Выполняет запрос и возвращает JSON-ответ.""" @@ -222,6 +240,7 @@ def request_json( data=data, files=files, headers=headers, + idempotency_key=idempotency_key, ) try: return response.json() @@ -235,6 +254,35 @@ def request_json( headers=dict(response.headers), ) from exc + def request_public_model[ModelT]( + self, + method: HttpMethod, + path: str, + *, + context: RequestContext, + mapper: Callable[[object], ModelT], + params: Mapping[str, object] | None = None, + json_body: object | None = None, + data: Mapping[str, object] | None = None, + files: Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, + idempotency_key: str | None = None, + ) -> ModelT: + """Выполняет запрос, получает JSON и маппит его в публичную SDK-модель.""" + + payload = self.request_json( + method, + path, + context=context, + params=params, + json_body=json_body, + data=data, + files=files, + headers=headers, + idempotency_key=idempotency_key, + ) + return mapper(payload) + def download_binary( self, path: str, @@ -310,15 +358,35 @@ def _merge_headers( *, context: RequestContext, headers: Mapping[str, str] | None, + idempotency_key: str | None, ) -> dict[str, str]: - merged: dict[str, str] = {"Accept": "application/json"} + merged: dict[str, str] = { + "Accept": "application/json", + "User-Agent": self._user_agent, + } merged.update(dict(context.headers)) if headers is not None: merged.update(dict(headers)) + if idempotency_key is not None: + merged["Idempotency-Key"] = idempotency_key if context.requires_auth and self._auth_provider is not None: merged["Authorization"] = f"Bearer {self._auth_provider.get_access_token()}" return merged + def _build_user_agent(self) -> str: + try: + package_version = importlib_metadata.version("avito-py") + except importlib_metadata.PackageNotFoundError: + package_version = "0+unknown" + user_agent = ( + f"avito-py/{package_version} " + f"python/{platform.python_version()} " + f"httpx/{httpx.__version__}" + ) + if self._settings.user_agent_suffix is not None: + user_agent += f" {self._settings.user_agent_suffix}" + return user_agent + def _decide_transport_retry( self, *, @@ -326,12 +394,17 @@ def _decide_transport_retry( attempt: int, context: RequestContext, is_timeout: bool, + idempotency_key: str | None, ) -> RetryDecision: if attempt >= self._retry_policy.max_attempts: return RetryDecision(False) if not self._retry_policy.retry_on_transport_error: return RetryDecision(False) - if not self._retry_policy.is_retryable_method(method, explicit_retry=context.allow_retry): + if not self._is_retryable_request( + method=method, + context=context, + idempotency_key=idempotency_key, + ): return RetryDecision(False) return RetryDecision( True, @@ -346,10 +419,15 @@ def _decide_http_retry( attempt: int, context: RequestContext, response: httpx.Response, + idempotency_key: str | None, ) -> RetryDecision: if attempt >= self._retry_policy.max_attempts: return RetryDecision(False) - if not self._retry_policy.is_retryable_method(method, explicit_retry=context.allow_retry): + if not self._is_retryable_request( + method=method, + context=context, + idempotency_key=idempotency_key, + ): return RetryDecision(False) if response.status_code == 429: if not self._retry_policy.retry_on_rate_limit: @@ -366,6 +444,21 @@ def _decide_http_retry( ) return RetryDecision(False) + def _is_retryable_request( + self, + *, + method: str, + context: RequestContext, + idempotency_key: str | None, + ) -> bool: + normalized_method = method.upper() + if normalized_method in {"POST", "PATCH"} and idempotency_key is None: + return False + return self._retry_policy.is_retryable_method( + normalized_method, + explicit_retry=context.allow_retry, + ) + def _map_http_error( self, response: httpx.Response, *, operation: str | None = None ) -> Exception: diff --git a/avito/cpa/client.py b/avito/cpa/client.py index 2df360c..011355f 100644 --- a/avito/cpa/client.py +++ b/avito/cpa/client.py @@ -40,7 +40,7 @@ ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class CpaChatsClient: """Выполняет HTTP-операции CPA-чатов.""" @@ -55,78 +55,104 @@ def get_by_action_id(self, *, action_id: int | str) -> CpaChatInfo: mapper=map_chat_item, ) - def list_by_time_classic(self, request: CpaChatsByTimeRequest) -> CpaChatsResult: + def list_by_time_classic(self, *, created_at_from: str, limit: int | None = None) -> CpaChatsResult: return request_public_model( self.transport, "POST", "/cpa/v1/chatsByTime", context=RequestContext("cpa.chats.list_by_time_classic", allow_retry=True), mapper=map_chats, - json_body=request.to_payload(), + json_body=CpaChatsByTimeRequest( + created_at_from=created_at_from, + limit=limit, + ).to_payload(), ) - def list_by_time(self, request: CpaChatsByTimeRequest) -> CpaChatsResult: + def list_by_time(self, *, created_at_from: str, limit: int | None = None) -> CpaChatsResult: return request_public_model( self.transport, "POST", "/cpa/v2/chatsByTime", context=RequestContext("cpa.chats.list_by_time", allow_retry=True), mapper=map_chats, - json_body=request.to_payload(), + json_body=CpaChatsByTimeRequest( + created_at_from=created_at_from, + limit=limit, + ).to_payload(), ) - def get_phones_info(self, request: CpaPhonesFromChatsRequest) -> CpaPhonesResult: + def get_phones_info(self, *, action_ids: list[str]) -> 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(), + json_body=CpaPhonesFromChatsRequest(action_ids=action_ids).to_payload(), ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class CpaCallsClient: """Выполняет HTTP-операции CPA-звонков.""" transport: Transport - def list_by_time(self, request: CpaCallsByTimeRequest) -> CpaCallsResult: + def list_by_time(self, *, date_time_from: str, date_time_to: str) -> CpaCallsResult: return request_public_model( self.transport, "POST", "/cpa/v2/callsByTime", context=RequestContext("cpa.calls.list_by_time", allow_retry=True), mapper=map_calls, - json_body=request.to_payload(), + json_body=CpaCallsByTimeRequest( + date_time_from=date_time_from, + date_time_to=date_time_to, + ).to_payload(), ) - def create_complaint(self, request: CpaCallComplaintRequest) -> CpaActionResult: + def create_complaint( + self, + *, + call_id: int, + reason: str, + idempotency_key: str | None = None, + ) -> CpaActionResult: return request_public_model( self.transport, "POST", "/cpa/v1/createComplaint", - context=RequestContext("cpa.calls.create_complaint", allow_retry=True), + context=RequestContext("cpa.calls.create_complaint", allow_retry=idempotency_key is not None), mapper=map_cpa_action, - json_body=request.to_payload(), + json_body=CpaCallComplaintRequest(call_id=call_id, reason=reason).to_payload(), + idempotency_key=idempotency_key, ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class CpaLeadsClient: """Выполняет HTTP-операции CPA-лидов и связанных сущностей.""" transport: Transport - def create_complaint_by_action_id(self, request: CpaLeadComplaintRequest) -> CpaActionResult: + def create_complaint_by_action_id( + self, + *, + action_id: str, + reason: str, + idempotency_key: str | None = None, + ) -> CpaActionResult: return request_public_model( self.transport, "POST", "/cpa/v1/createComplaintByActionId", - context=RequestContext("cpa.leads.create_complaint_by_action_id", allow_retry=True), + context=RequestContext( + "cpa.leads.create_complaint_by_action_id", + allow_retry=idempotency_key is not None, + ), mapper=map_cpa_action, - json_body=request.to_payload(), + json_body=CpaLeadComplaintRequest(action_id=action_id, reason=reason).to_payload(), + idempotency_key=idempotency_key, ) def get_balance_info(self) -> CpaBalanceInfo: @@ -140,7 +166,7 @@ def get_balance_info(self) -> CpaBalanceInfo: ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class CpaArchiveClient: """Выполняет архивные HTTP-операции CPA.""" @@ -163,41 +189,53 @@ def get_balance_info(self) -> CpaBalanceInfo: json_body={}, ) - def get_call_by_id(self, request: CpaCallByIdRequest) -> CpaCallInfo: + def get_call_by_id(self, *, call_id: int) -> CpaCallInfo: return request_public_model( self.transport, "POST", "/cpa/v2/callById", context=RequestContext("cpa.archive.get_call_by_id", allow_retry=True), mapper=map_call_item, - json_body=request.to_payload(), + json_body=CpaCallByIdRequest(call_id=call_id).to_payload(), ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class CallTrackingClient: """Выполняет HTTP-операции CallTracking.""" transport: Transport - def get_call_by_id(self, request: CallTrackingGetCallByIdRequest) -> CallTrackingCallResponse: + def get_call_by_id(self, *, call_id: int) -> 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(), + json_body=CallTrackingGetCallByIdRequest(call_id=call_id).to_payload(), ) - def get_calls(self, request: CallTrackingCallsRequest) -> CallTrackingCallsResult: + def get_calls( + self, + *, + date_time_from: str, + date_time_to: str, + limit: int | None = None, + offset: int | None = None, + ) -> 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(), + json_body=CallTrackingCallsRequest( + date_time_from=date_time_from, + date_time_to=date_time_to, + limit=limit, + offset=offset, + ).to_payload(), ) def get_record_by_call_id(self, *, call_id: int | str) -> CallTrackingRecord: diff --git a/avito/cpa/domain.py b/avito/cpa/domain.py index f01c643..374202e 100644 --- a/avito/cpa/domain.py +++ b/avito/cpa/domain.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass from avito.core import ValidationError @@ -15,23 +16,15 @@ ) from avito.cpa.models import ( CallTrackingCallResponse, - CallTrackingCallsRequest, CallTrackingCallsResult, - CallTrackingGetCallByIdRequest, CallTrackingRecord, CpaActionResult, CpaAudioRecord, CpaBalanceInfo, - CpaCallByIdRequest, - CpaCallComplaintRequest, CpaCallInfo, - CpaCallsByTimeRequest, CpaCallsResult, CpaChatInfo, - CpaChatsByTimeRequest, CpaChatsResult, - CpaLeadComplaintRequest, - CpaPhonesFromChatsRequest, CpaPhonesResult, ) @@ -45,9 +38,13 @@ class CpaLead(DomainObject): def create_complaint_by_action_id( self, *, - request: CpaLeadComplaintRequest, + action_id: str, + reason: str, ) -> CpaActionResult: - return CpaLeadsClient(self.transport).create_complaint_by_action_id(request) + return CpaLeadsClient(self.transport).create_complaint_by_action_id( + action_id=action_id, + reason=reason, + ) def get_balance_info(self) -> CpaBalanceInfo: return CpaLeadsClient(self.transport).get_balance_info() @@ -68,20 +65,21 @@ def get(self, *, action_id: int | str | None = None) -> CpaChatInfo: def list( self, *, - request: CpaChatsByTimeRequest, + created_at_from: str, + limit: int | None = None, version: int = 2, ) -> CpaChatsResult: client = CpaChatsClient(self.transport) if version == 1: - return client.list_by_time_classic(request) - return client.list_by_time(request) + return client.list_by_time_classic(created_at_from=created_at_from, limit=limit) + return client.list_by_time(created_at_from=created_at_from, limit=limit) def get_phones_info_from_chats( self, *, - request: CpaPhonesFromChatsRequest, + action_ids: Sequence[str], ) -> CpaPhonesResult: - return CpaChatsClient(self.transport).get_phones_info(request) + return CpaChatsClient(self.transport).get_phones_info(action_ids=list(action_ids)) def _require_action_id(self) -> str: if self.action_id is None: @@ -97,16 +95,12 @@ class CpaCall(DomainObject): 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, - ) + date_time_from=date_time_from, + date_time_to=date_time_to, ) def create_complaint(self, *, call_id: int, reason: str) -> CpaActionResult: - return CpaCallsClient(self.transport).create_complaint( - CpaCallComplaintRequest(call_id=call_id, reason=reason) - ) + return CpaCallsClient(self.transport).create_complaint(call_id=call_id, reason=reason) @dataclass(slots=True, frozen=True) @@ -125,9 +119,7 @@ def get_balance_info(self) -> CpaBalanceInfo: return CpaArchiveClient(self.transport).get_balance_info() def get_call_by_id(self, *, call_id: int) -> CpaCallInfo: - return CpaArchiveClient(self.transport).get_call_by_id( - CpaCallByIdRequest(call_id=call_id) - ) + return CpaArchiveClient(self.transport).get_call_by_id(call_id=call_id) def _require_call_id(self) -> str: if self.call_id is None: @@ -148,9 +140,7 @@ def get(self, *, call_id: int | None = None) -> CallTrackingCallResponse: ) if resolved_call_id is None: raise ValidationError("Для операции требуется `call_id`.") - return CallTrackingClient(self.transport).get_call_by_id( - CallTrackingGetCallByIdRequest(call_id=resolved_call_id) - ) + return CallTrackingClient(self.transport).get_call_by_id(call_id=resolved_call_id) def list( self, @@ -161,12 +151,10 @@ def list( 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, - ) + 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: diff --git a/avito/cpa/enums.py b/avito/cpa/enums.py new file mode 100644 index 0000000..0fcdb77 --- /dev/null +++ b/avito/cpa/enums.py @@ -0,0 +1,3 @@ +"""Enum-значения раздела cpa.""" + +__all__: tuple[str, ...] = () diff --git a/avito/jobs/__init__.py b/avito/jobs/__init__.py index f5b1e52..d163b75 100644 --- a/avito/jobs/__init__.py +++ b/avito/jobs/__init__.py @@ -1,6 +1,7 @@ """Пакет jobs.""" from avito.jobs.domain import Application, JobDictionary, JobWebhook, Resume, Vacancy +from avito.jobs.enums import ApplicationStatus, JobActionStatus, VacancyStatus from avito.jobs.models import ( ApplicationActionRequest, ApplicationIdsQuery, @@ -39,10 +40,12 @@ "ApplicationIdsQuery", "ApplicationIdsRequest", "ApplicationsResult", + "ApplicationStatus", "ApplicationStatesResult", "ApplicationViewedItem", "ApplicationViewedRequest", "JobActionResult", + "JobActionStatus", "JobDictionariesResult", "JobDictionary", "JobDictionaryValuesResult", @@ -66,4 +69,5 @@ "VacancyStatusesResult", "VacanciesQuery", "VacancyUpdateRequest", + "VacancyStatus", ) diff --git a/avito/jobs/client.py b/avito/jobs/client.py index e8fdba5..9716b39 100644 --- a/avito/jobs/client.py +++ b/avito/jobs/client.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass from avito.core import RequestContext, Transport @@ -29,6 +30,7 @@ ApplicationIdsResult, ApplicationsResult, ApplicationStatesResult, + ApplicationViewedItem, ApplicationViewedRequest, JobActionResult, JobDictionariesResult, @@ -53,25 +55,34 @@ ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class ApplicationsClient: """Выполняет HTTP-операции откликов.""" transport: Transport - def apply_actions(self, request: ApplicationActionRequest) -> JobActionResult: + def apply_actions( + self, + *, + ids: list[str], + action: str, + idempotency_key: str | None = None, + ) -> JobActionResult: return self._post_action( - "/job/v1/applications/apply_actions", "jobs.applications.apply_actions", request + "/job/v1/applications/apply_actions", + "jobs.applications.apply_actions", + ApplicationActionRequest(ids=ids, action=action), + idempotency_key=idempotency_key, ) - def get_by_ids(self, request: ApplicationIdsRequest) -> ApplicationsResult: + def get_by_ids(self, *, ids: list[str]) -> 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(), + json_body=ApplicationIdsRequest(ids=ids).to_payload(), ) def get_ids(self, *, query: ApplicationIdsQuery) -> ApplicationIdsResult: @@ -93,9 +104,17 @@ def get_states(self) -> ApplicationStatesResult: mapper=map_application_states, ) - def set_is_viewed(self, request: ApplicationViewedRequest) -> JobActionResult: + def set_is_viewed( + self, + *, + applies: list[ApplicationViewedItem], + idempotency_key: str | None = None, + ) -> JobActionResult: return self._post_action( - "/job/v1/applications/set_is_viewed", "jobs.applications.set_is_viewed", request + "/job/v1/applications/set_is_viewed", + "jobs.applications.set_is_viewed", + ApplicationViewedRequest(applies=applies), + idempotency_key=idempotency_key, ) def _post_action( @@ -103,18 +122,20 @@ def _post_action( path: str, operation: str, request: ApplicationActionRequest | ApplicationViewedRequest, + idempotency_key: str | None = None, ) -> JobActionResult: return request_public_model( self.transport, "POST", path, - context=RequestContext(operation, allow_retry=True), + context=RequestContext(operation, allow_retry=idempotency_key is not None), mapper=map_job_action, json_body=request.to_payload(), + idempotency_key=idempotency_key, ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class WebhookClient: """Выполняет HTTP-операции webhook откликов.""" @@ -129,24 +150,28 @@ def get_webhook(self) -> JobWebhookInfo: mapper=map_job_webhook, ) - def put_webhook(self, request: JobWebhookUpdateRequest) -> JobWebhookInfo: + def put_webhook(self, *, url: str, idempotency_key: str | None = None) -> JobWebhookInfo: return request_public_model( self.transport, "PUT", "/job/v1/applications/webhook", - context=RequestContext("jobs.webhook.put", allow_retry=True), + context=RequestContext("jobs.webhook.put", allow_retry=idempotency_key is not None), mapper=map_job_webhook, - json_body=request.to_payload(), + json_body=JobWebhookUpdateRequest(url=url).to_payload(), + idempotency_key=idempotency_key, ) - def delete_webhook(self, *, url: str | None = None) -> JobActionResult: + def delete_webhook( + self, *, url: str | None = None, idempotency_key: str | None = None + ) -> JobActionResult: return request_public_model( self.transport, "DELETE", "/job/v1/applications/webhook", - context=RequestContext("jobs.webhook.delete", allow_retry=True), + context=RequestContext("jobs.webhook.delete", allow_retry=idempotency_key is not None), mapper=map_job_action, params={"url": url}, + idempotency_key=idempotency_key, ) def list_webhooks(self) -> JobWebhooksResult: @@ -159,7 +184,7 @@ def list_webhooks(self) -> JobWebhooksResult: ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class ResumeClient: """Выполняет HTTP-операции резюме.""" @@ -194,65 +219,81 @@ def get_item(self, *, resume_id: str) -> ResumeInfo: ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class VacanciesClient: """Выполняет HTTP-операции вакансий.""" transport: Transport - def create_classic(self, request: VacancyCreateRequest) -> JobActionResult: + def create_classic(self, *, title: str, idempotency_key: str | None = None) -> JobActionResult: return request_public_model( self.transport, "POST", "/job/v1/vacancies", - context=RequestContext("jobs.vacancies.create_classic", allow_retry=True), + context=RequestContext( + "jobs.vacancies.create_classic", + allow_retry=idempotency_key is not None, + ), mapper=map_job_action, - json_body=request.to_payload(), + json_body=VacancyCreateRequest(title=title).to_payload(), + idempotency_key=idempotency_key, ) def archive( self, *, vacancy_id: int | str, - request: VacancyArchiveRequest, + employee_id: int, + idempotency_key: str | None = None, ) -> JobActionResult: return request_public_model( self.transport, "PUT", f"/job/v1/vacancies/archived/{vacancy_id}", - context=RequestContext("jobs.vacancies.archive", allow_retry=True), + context=RequestContext("jobs.vacancies.archive", allow_retry=idempotency_key is not None), mapper=map_job_action, - json_body=request.to_payload(), + json_body=VacancyArchiveRequest(employee_id=employee_id).to_payload(), + idempotency_key=idempotency_key, ) def update_classic( self, *, vacancy_id: int | str, - request: VacancyUpdateRequest, + title: str, + idempotency_key: str | None = None, ) -> JobActionResult: return request_public_model( self.transport, "PUT", f"/job/v1/vacancies/{vacancy_id}", - context=RequestContext("jobs.vacancies.update_classic", allow_retry=True), + context=RequestContext( + "jobs.vacancies.update_classic", + allow_retry=idempotency_key is not None, + ), mapper=map_job_action, - json_body=request.to_payload(), + json_body=VacancyUpdateRequest(title=title).to_payload(), + idempotency_key=idempotency_key, ) def prolongate( self, *, vacancy_id: int | str, - request: VacancyProlongateRequest, + billing_type: str, + idempotency_key: str | None = None, ) -> JobActionResult: return request_public_model( self.transport, "POST", f"/job/v1/vacancies/{vacancy_id}/prolongate", - context=RequestContext("jobs.vacancies.prolongate", allow_retry=True), + context=RequestContext( + "jobs.vacancies.prolongate", + allow_retry=idempotency_key is not None, + ), mapper=map_job_action, - json_body=request.to_payload(), + json_body=VacancyProlongateRequest(billing_type=billing_type).to_payload(), + idempotency_key=idempotency_key, ) def list(self, *, query: VacanciesQuery | None = None) -> VacanciesResult: @@ -265,44 +306,52 @@ def list(self, *, query: VacanciesQuery | None = None) -> VacanciesResult: params=query.to_params() if query is not None else None, ) - def create(self, request: VacancyCreateRequest) -> JobActionResult: + def create(self, *, title: str, idempotency_key: str | None = None) -> JobActionResult: return request_public_model( self.transport, "POST", "/job/v2/vacancies", - context=RequestContext("jobs.vacancies.create", allow_retry=True), + context=RequestContext("jobs.vacancies.create", allow_retry=idempotency_key is not None), mapper=map_job_action, - json_body=request.to_payload(), + json_body=VacancyCreateRequest(title=title).to_payload(), + idempotency_key=idempotency_key, ) - def get_by_ids(self, request: VacancyIdsRequest) -> VacanciesResult: + def get_by_ids(self, *, ids: Sequence[int]) -> VacanciesResult: return request_public_model( self.transport, "POST", "/job/v2/vacancies/batch", context=RequestContext("jobs.vacancies.get_by_ids", allow_retry=True), mapper=map_vacancies, - json_body=request.to_payload(), + json_body=VacancyIdsRequest(ids=list(ids)).to_payload(), ) - def get_statuses(self, request: VacancyIdsRequest) -> VacancyStatusesResult: + def get_statuses(self, *, ids: Sequence[int]) -> VacancyStatusesResult: return request_public_model( self.transport, "POST", "/job/v2/vacancies/statuses", context=RequestContext("jobs.vacancies.get_statuses", allow_retry=True), mapper=map_vacancy_statuses, - json_body=request.to_payload(), + json_body=VacancyIdsRequest(ids=list(ids)).to_payload(), ) - def update(self, *, vacancy_uuid: str, request: VacancyUpdateRequest) -> JobActionResult: + def update( + self, + *, + vacancy_uuid: str, + title: str, + idempotency_key: str | None = None, + ) -> JobActionResult: return request_public_model( self.transport, "POST", f"/job/v2/vacancies/update/{vacancy_uuid}", - context=RequestContext("jobs.vacancies.update", allow_retry=True), + context=RequestContext("jobs.vacancies.update", allow_retry=idempotency_key is not None), mapper=map_job_action, - json_body=request.to_payload(), + json_body=VacancyUpdateRequest(title=title).to_payload(), + idempotency_key=idempotency_key, ) def get_item( @@ -321,19 +370,24 @@ def update_auto_renewal( self, *, vacancy_uuid: str, - request: VacancyAutoRenewalRequest, + auto_renewal: bool, + idempotency_key: str | None = None, ) -> JobActionResult: return request_public_model( self.transport, "PUT", f"/job/v2/vacancies/{vacancy_uuid}/auto_renewal", - context=RequestContext("jobs.vacancies.update_auto_renewal", allow_retry=True), + context=RequestContext( + "jobs.vacancies.update_auto_renewal", + allow_retry=idempotency_key is not None, + ), mapper=map_job_action, - json_body=request.to_payload(), + json_body=VacancyAutoRenewalRequest(auto_renewal=auto_renewal).to_payload(), + idempotency_key=idempotency_key, ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class DictionariesClient: """Выполняет HTTP-операции словарей вакансий.""" diff --git a/avito/jobs/domain.py b/avito/jobs/domain.py index ee98fa0..5107e7e 100644 --- a/avito/jobs/domain.py +++ b/avito/jobs/domain.py @@ -15,34 +15,24 @@ WebhookClient, ) from avito.jobs.models import ( - ApplicationActionRequest, ApplicationIdsQuery, - ApplicationIdsRequest, ApplicationIdsResult, ApplicationsResult, ApplicationStatesResult, ApplicationViewedItem, - ApplicationViewedRequest, JobActionResult, JobDictionariesResult, JobDictionaryValuesResult, JobWebhookInfo, JobWebhooksResult, - JobWebhookUpdateRequest, ResumeContactInfo, ResumeInfo, ResumeSearchQuery, ResumesResult, VacanciesQuery, VacanciesResult, - VacancyArchiveRequest, - VacancyAutoRenewalRequest, - VacancyCreateRequest, - VacancyIdsRequest, VacancyInfo, - VacancyProlongateRequest, VacancyStatusesResult, - VacancyUpdateRequest, ) @@ -53,44 +43,64 @@ class Vacancy(DomainObject): vacancy_id: int | str | None = None user_id: int | str | None = None - def create(self, *, title: str, version: int = 2) -> JobActionResult: + def create( + self, + *, + title: str, + version: int = 2, + idempotency_key: str | None = None, + ) -> JobActionResult: client = VacanciesClient(self.transport) - request = VacancyCreateRequest(title=title) if version == 1: - return client.create_classic(request) - return client.create(request) + return client.create_classic(title=title, idempotency_key=idempotency_key) + return client.create(title=title, idempotency_key=idempotency_key) def update( self, *, - request: VacancyUpdateRequest, + title: str, vacancy_id: int | str | None = None, vacancy_uuid: str | None = None, version: int = 2, + idempotency_key: str | None = None, ) -> JobActionResult: client = VacanciesClient(self.transport) if version == 1: return client.update_classic( - vacancy_id=vacancy_id or self._require_vacancy_id(), request=request + vacancy_id=vacancy_id or self._require_vacancy_id(), + title=title, + idempotency_key=idempotency_key, ) return client.update( - vacancy_uuid=vacancy_uuid or self._require_vacancy_id(), request=request + vacancy_uuid=vacancy_uuid or self._require_vacancy_id(), + title=title, + idempotency_key=idempotency_key, ) def delete( - self, *, request: VacancyArchiveRequest, vacancy_id: int | str | None = None + self, + *, + employee_id: int, + vacancy_id: int | str | None = None, + idempotency_key: str | None = None, ) -> JobActionResult: return VacanciesClient(self.transport).archive( vacancy_id=vacancy_id or self._require_vacancy_id(), - request=request, + employee_id=employee_id, + idempotency_key=idempotency_key, ) def prolongate( - self, *, request: VacancyProlongateRequest, vacancy_id: int | str | None = None + self, + *, + billing_type: str, + vacancy_id: int | str | None = None, + idempotency_key: str | None = None, ) -> JobActionResult: return VacanciesClient(self.transport).prolongate( vacancy_id=vacancy_id or self._require_vacancy_id(), - request=request, + billing_type=billing_type, + idempotency_key=idempotency_key, ) def list(self, *, query: VacanciesQuery | None = None) -> VacanciesResult: @@ -105,17 +115,22 @@ def get( ) def get_by_ids(self, *, ids: Sequence[int]) -> VacanciesResult: - return VacanciesClient(self.transport).get_by_ids(VacancyIdsRequest(ids=list(ids))) + return VacanciesClient(self.transport).get_by_ids(ids=list(ids)) def get_statuses(self, *, ids: Sequence[int]) -> VacancyStatusesResult: - return VacanciesClient(self.transport).get_statuses(VacancyIdsRequest(ids=list(ids))) + return VacanciesClient(self.transport).get_statuses(ids=list(ids)) def update_auto_renewal( - self, *, request: VacancyAutoRenewalRequest, vacancy_uuid: str | None = None + self, + *, + auto_renewal: bool, + vacancy_uuid: str | None = None, + idempotency_key: str | None = None, ) -> JobActionResult: return VacanciesClient(self.transport).update_auto_renewal( vacancy_uuid=vacancy_uuid or self._require_vacancy_id(), - request=request, + auto_renewal=auto_renewal, + idempotency_key=idempotency_key, ) def _require_vacancy_id(self) -> str: @@ -130,30 +145,44 @@ class Application(DomainObject): user_id: int | str | None = None - def apply(self, *, ids: Sequence[str], action: str) -> JobActionResult: + def apply( + self, + *, + ids: Sequence[str], + action: str, + idempotency_key: str | None = None, + ) -> JobActionResult: return ApplicationsClient(self.transport).apply_actions( - ApplicationActionRequest(ids=list(ids), action=action) + ids=list(ids), + action=action, + idempotency_key=idempotency_key, ) def list( self, *, - request: ApplicationIdsRequest | None = None, + ids: Sequence[str] | None = None, query: ApplicationIdsQuery | None = None, ) -> ApplicationsResult | ApplicationIdsResult: client = ApplicationsClient(self.transport) - if request is not None: - return client.get_by_ids(request) + if ids is not None: + return client.get_by_ids(ids=list(ids)) if query is None: - raise ValidationError("Для операции требуется `query` или `request`.") + raise ValidationError("Для операции требуется `query` или `ids`.") return client.get_ids(query=query) def get_states(self) -> ApplicationStatesResult: return ApplicationsClient(self.transport).get_states() - def update(self, *, applies: Sequence[ApplicationViewedItem]) -> JobActionResult: + def update( + self, + *, + applies: Sequence[ApplicationViewedItem], + idempotency_key: str | None = None, + ) -> JobActionResult: return ApplicationsClient(self.transport).set_is_viewed( - ApplicationViewedRequest(applies=list(applies)) + applies=list(applies), + idempotency_key=idempotency_key, ) @@ -195,11 +224,21 @@ def get(self) -> JobWebhookInfo: def list(self) -> JobWebhooksResult: return WebhookClient(self.transport).list_webhooks() - def update(self, *, url: str) -> JobWebhookInfo: - return WebhookClient(self.transport).put_webhook(JobWebhookUpdateRequest(url=url)) + def update( + self, *, url: str, idempotency_key: str | None = None + ) -> JobWebhookInfo: + return WebhookClient(self.transport).put_webhook( + url=url, + idempotency_key=idempotency_key, + ) - def delete(self, *, url: str | None = None) -> JobActionResult: - return WebhookClient(self.transport).delete_webhook(url=url) + def delete( + self, *, url: str | None = None, idempotency_key: str | None = None + ) -> JobActionResult: + return WebhookClient(self.transport).delete_webhook( + url=url, + idempotency_key=idempotency_key, + ) @dataclass(slots=True, frozen=True) diff --git a/avito/jobs/enums.py b/avito/jobs/enums.py new file mode 100644 index 0000000..1232c49 --- /dev/null +++ b/avito/jobs/enums.py @@ -0,0 +1,37 @@ +"""Enum-значения раздела jobs.""" + +from __future__ import annotations + +from enum import Enum + + +class JobActionStatus(str, Enum): + """Статус мутационной операции jobs.""" + + UNKNOWN = "__unknown__" + VIEWED = "viewed" + INVITED = "invited" + CREATED = "created" + UPDATED = "updated" + ARCHIVED = "archived" + PROLONGATED = "prolongated" + AUTO_RENEWAL_UPDATED = "auto-renewal-updated" + + +class ApplicationStatus(str, Enum): + """Статус отклика.""" + + UNKNOWN = "__unknown__" + NEW = "new" + + +class VacancyStatus(str, Enum): + """Статус вакансии.""" + + UNKNOWN = "__unknown__" + ACTIVE = "active" + CREATED = "created" + UPDATED = "updated" + + +__all__ = ("ApplicationStatus", "JobActionStatus", "VacancyStatus") diff --git a/avito/jobs/mappers.py b/avito/jobs/mappers.py index 1666278..25db9fa 100644 --- a/avito/jobs/mappers.py +++ b/avito/jobs/mappers.py @@ -5,7 +5,9 @@ from collections.abc import Mapping from typing import cast +from avito.core.enums import map_enum_or_unknown from avito.core.exceptions import ResponseMappingError +from avito.jobs.enums import ApplicationStatus, JobActionStatus, VacancyStatus from avito.jobs.models import ( ApplicationIdItem, ApplicationIdsResult, @@ -97,7 +99,11 @@ def map_job_action(payload: object) -> JobActionResult: return JobActionResult( success=bool(source.get("ok", source.get("success", True))), id=identifier or (str(numeric_id) if numeric_id is not None else None), - status=_str(source, "status", "state"), + status=map_enum_or_unknown( + _str(source, "status", "state"), + JobActionStatus, + enum_name="jobs.action_status", + ), message=_str(source, "message"), ) @@ -107,7 +113,11 @@ def map_application(payload: Payload) -> ApplicationInfo: id=_str(payload, "id"), vacancy_id=_int(payload, "vacancy_id", "vacancyId"), resume_id=_str(payload, "resume_id", "resumeId"), - state=_str(payload, "state", "status"), + state=map_enum_or_unknown( + _str(payload, "state", "status"), + ApplicationStatus, + enum_name="jobs.application_status", + ), is_viewed=_bool(payload, "is_viewed", "isViewed"), applicant_name=_str(_mapping(payload, "applicant"), "name", "fullName"), ) @@ -208,7 +218,11 @@ def map_vacancy(payload: Payload) -> VacancyInfo: ), uuid=_str(payload, "uuid", "vacancy_uuid", "vacancyUuid"), title=_str(payload, "title", "name"), - status=_str(payload, "status", "state"), + status=map_enum_or_unknown( + _str(payload, "status", "state"), + VacancyStatus, + enum_name="jobs.vacancy_status", + ), url=_str(payload, "url"), ) @@ -248,7 +262,11 @@ def map_vacancy_statuses(payload: object) -> VacancyStatusesResult: else None ), uuid=_str(item, "uuid", "vacancy_uuid"), - status=_str(item, "status", "state"), + status=map_enum_or_unknown( + _str(item, "status", "state"), + VacancyStatus, + enum_name="jobs.vacancy_status", + ), ) for item in _list(data, "items", "statuses", "vacancies", "result") ], diff --git a/avito/jobs/models.py b/avito/jobs/models.py index 8e97adf..ba870f8 100644 --- a/avito/jobs/models.py +++ b/avito/jobs/models.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from avito.core.serialization import SerializableModel +from avito.jobs.enums import ApplicationStatus, JobActionStatus, VacancyStatus @dataclass(slots=True, frozen=True) @@ -186,7 +187,7 @@ class JobActionResult(SerializableModel): success: bool id: str | None = None - status: str | None = None + status: JobActionStatus | None = None message: str | None = None @@ -197,7 +198,7 @@ class ApplicationInfo(SerializableModel): id: str | None vacancy_id: int | None resume_id: str | None - state: str | None + state: ApplicationStatus | None is_viewed: bool | None applicant_name: str | None @@ -276,7 +277,7 @@ class VacancyInfo(SerializableModel): id: str | None uuid: str | None title: str | None - status: str | None + status: VacancyStatus | None url: str | None @@ -294,7 +295,7 @@ class VacancyStatusInfo(SerializableModel): id: str | None uuid: str | None - status: str | None + status: VacancyStatus | None @dataclass(slots=True, frozen=True) diff --git a/avito/messenger/__init__.py b/avito/messenger/__init__.py index 29c7422..3efd1ee 100644 --- a/avito/messenger/__init__.py +++ b/avito/messenger/__init__.py @@ -7,6 +7,14 @@ ChatWebhook, SpecialOfferCampaign, ) +from avito.messenger.enums import ( + MessageActionStatus, + MessageDirection, + MessageType, + SpecialOfferCampaignStatus, + SubscriptionStatus, + WebhookStatus, +) from avito.messenger.models import ( ChatInfo, ChatsResult, @@ -32,18 +40,24 @@ "ChatMessage", "ChatWebhook", "ChatsResult", + "MessageActionStatus", "MessageActionResult", + "MessageDirection", "MessageInfo", + "MessageType", "MessagesResult", "MultiCreateSpecialOfferResult", "SpecialOfferAvailableResult", "SpecialOfferCampaign", + "SpecialOfferCampaignStatus", "SpecialOfferStatsResult", + "SubscriptionStatus", "SubscriptionsResult", "TariffInfo", "UploadImageFile", "UploadImagesRequest", "UploadImagesResult", "VoiceFilesResult", + "WebhookStatus", "WebhookActionResult", ) diff --git a/avito/messenger/client.py b/avito/messenger/client.py index bf6c8ed..34c9ca8 100644 --- a/avito/messenger/client.py +++ b/avito/messenger/client.py @@ -38,6 +38,7 @@ TariffInfo, UnsubscribeWebhookRequest, UpdateWebhookRequest, + UploadImageFile, UploadImagesRequest, UploadImagesResult, VoiceFilesResult, @@ -45,7 +46,7 @@ ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class MessengerClient: """Выполняет HTTP-операции чатов и сообщений.""" @@ -54,92 +55,127 @@ class MessengerClient: def list_chats(self, *, user_id: int) -> ChatsResult: """Получает список чатов пользователя.""" - payload = self.transport.request_json( + return self.transport.request_public_model( "GET", f"/messenger/v2/accounts/{user_id}/chats", context=RequestContext("messenger.list_chats"), + mapper=map_chats, ) - return map_chats(payload) def get_chat(self, *, user_id: int, chat_id: str) -> ChatInfo: """Получает информацию по чату.""" - payload = self.transport.request_json( + return self.transport.request_public_model( "GET", f"/messenger/v2/accounts/{user_id}/chats/{chat_id}", context=RequestContext("messenger.get_chat"), + mapper=map_chat, ) - return map_chat(payload) - def read_chat(self, *, user_id: int, chat_id: str) -> MessageActionResult: + def read_chat( + self, + *, + user_id: int, + chat_id: str, + idempotency_key: str | None = None, + ) -> MessageActionResult: """Помечает чат как прочитанный.""" - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", f"/messenger/v1/accounts/{user_id}/chats/{chat_id}/read", - context=RequestContext("messenger.read_chat", allow_retry=True), + context=RequestContext("messenger.read_chat", allow_retry=idempotency_key is not None), + mapper=map_message_action, + idempotency_key=idempotency_key, ) - return map_message_action(payload) - def add_to_blacklist(self, *, user_id: int, request: BlacklistRequest) -> MessageActionResult: + def add_to_blacklist( + self, *, user_id: int, blacklisted_user_id: int, idempotency_key: str | None = None + ) -> MessageActionResult: """Добавляет пользователя в blacklist.""" - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", f"/messenger/v2/accounts/{user_id}/blacklist", - context=RequestContext("messenger.add_to_blacklist", allow_retry=True), - json_body=request.to_payload(), + context=RequestContext( + "messenger.add_to_blacklist", + allow_retry=idempotency_key is not None, + ), + mapper=map_message_action, + json_body=BlacklistRequest(blacklisted_user_id=blacklisted_user_id).to_payload(), + idempotency_key=idempotency_key, ) - return map_message_action(payload) def send_message( - self, *, user_id: int, chat_id: str, request: SendMessageRequest + self, *, user_id: int, chat_id: str, message: str, idempotency_key: str | None = None ) -> MessageActionResult: """Отправляет текстовое сообщение.""" - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", f"/messenger/v1/accounts/{user_id}/chats/{chat_id}/messages", - context=RequestContext("messenger.send_message", allow_retry=True), - json_body=request.to_payload(), + context=RequestContext("messenger.send_message", allow_retry=idempotency_key is not None), + mapper=map_message_action, + json_body=SendMessageRequest(message=message).to_payload(), + idempotency_key=idempotency_key, ) - return map_message_action(payload) def send_image_message( - self, *, user_id: int, chat_id: str, request: SendImageMessageRequest + self, + *, + user_id: int, + chat_id: str, + image_id: str, + caption: str | None = None, + idempotency_key: str | None = None, ) -> MessageActionResult: """Отправляет сообщение с изображением.""" - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", f"/messenger/v1/accounts/{user_id}/chats/{chat_id}/messages/image", - context=RequestContext("messenger.send_image_message", allow_retry=True), - json_body=request.to_payload(), + context=RequestContext( + "messenger.send_image_message", + allow_retry=idempotency_key is not None, + ), + mapper=map_message_action, + json_body=SendImageMessageRequest(image_id=image_id, caption=caption).to_payload(), + idempotency_key=idempotency_key, ) - return map_message_action(payload) - def delete_message(self, *, user_id: int, chat_id: str, message_id: str) -> MessageActionResult: + def delete_message( + self, + *, + user_id: int, + chat_id: str, + message_id: str, + idempotency_key: str | None = None, + ) -> MessageActionResult: """Удаляет сообщение.""" - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", f"/messenger/v1/accounts/{user_id}/chats/{chat_id}/messages/{message_id}", - context=RequestContext("messenger.delete_message", allow_retry=True), + context=RequestContext( + "messenger.delete_message", + allow_retry=idempotency_key is not None, + ), + mapper=map_message_action, + idempotency_key=idempotency_key, ) - return map_message_action(payload) def list_messages(self, *, user_id: int, chat_id: str) -> MessagesResult: """Получает список сообщений V3.""" - payload = self.transport.request_json( + return self.transport.request_public_model( "GET", f"/messenger/v3/accounts/{user_id}/chats/{chat_id}/messages/", context=RequestContext("messenger.list_messages"), + mapper=map_messages, ) - return map_messages(payload) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class WebhookClient: """Выполняет HTTP-операции webhook мессенджера.""" @@ -148,37 +184,51 @@ class WebhookClient: def get_subscriptions(self) -> SubscriptionsResult: """Получает список подписок webhook.""" - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", "/messenger/v1/subscriptions", context=RequestContext("messenger.webhook.get_subscriptions", allow_retry=True), + mapper=map_subscriptions, ) - return map_subscriptions(payload) - def unsubscribe(self, request: UnsubscribeWebhookRequest) -> WebhookActionResult: + def unsubscribe(self, *, url: str, idempotency_key: str | None = None) -> WebhookActionResult: """Отключает webhook.""" - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", "/messenger/v1/webhook/unsubscribe", - context=RequestContext("messenger.webhook.unsubscribe", allow_retry=True), - json_body=request.to_payload(), + context=RequestContext( + "messenger.webhook.unsubscribe", + allow_retry=idempotency_key is not None, + ), + mapper=map_webhook_action, + json_body=UnsubscribeWebhookRequest(url=url).to_payload(), + idempotency_key=idempotency_key, ) - return map_webhook_action(payload) - def update_v3(self, request: UpdateWebhookRequest) -> WebhookActionResult: + def update_v3( + self, + *, + url: str, + secret: str | None = None, + idempotency_key: str | None = None, + ) -> WebhookActionResult: """Включает уведомления webhook v3.""" - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", "/messenger/v3/webhook", - context=RequestContext("messenger.webhook.update_v3", allow_retry=True), - json_body=request.to_payload(), + context=RequestContext( + "messenger.webhook.update_v3", + allow_retry=idempotency_key is not None, + ), + mapper=map_webhook_action, + json_body=UpdateWebhookRequest(url=url, secret=secret).to_payload(), + idempotency_key=idempotency_key, ) - return map_webhook_action(payload) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class MediaClient: """Выполняет HTTP-операции media uploads и voice files.""" @@ -187,91 +237,115 @@ class MediaClient: def get_voice_files(self, *, user_id: int) -> VoiceFilesResult: """Получает голосовые сообщения.""" - payload = self.transport.request_json( + return self.transport.request_public_model( "GET", f"/messenger/v1/accounts/{user_id}/getVoiceFiles", context=RequestContext("messenger.media.get_voice_files"), + mapper=map_voice_files, ) - return map_voice_files(payload) def upload_images( self, *, user_id: int, - request: UploadImagesRequest, + files: list[UploadImageFile], + idempotency_key: str | None = None, ) -> UploadImagesResult: """Загружает изображения для сообщений.""" - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", f"/messenger/v1/accounts/{user_id}/uploadImages", - context=RequestContext("messenger.media.upload_images", allow_retry=True), - files=request.to_files(), + context=RequestContext( + "messenger.media.upload_images", + allow_retry=idempotency_key is not None, + ), + mapper=map_upload_images, + files=UploadImagesRequest(files=files).to_files(), + idempotency_key=idempotency_key, ) - return map_upload_images(payload) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class SpecialOffersClient: """Выполняет HTTP-операции рассылок скидок и спецпредложений.""" transport: Transport - def get_available(self, request: SpecialOfferAvailableRequest) -> SpecialOfferAvailableResult: + def get_available(self, *, item_ids: list[int]) -> SpecialOfferAvailableResult: """Получает доступные объявления для рассылки.""" - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", "/special-offers/v1/available", context=RequestContext("messenger.special_offers.get_available", allow_retry=True), - json_body=request.to_payload(), + mapper=map_available_special_offers, + json_body=SpecialOfferAvailableRequest(item_ids=item_ids).to_payload(), ) - return map_available_special_offers(payload) def create_multi( - self, request: MultiCreateSpecialOfferRequest + self, + *, + item_ids: list[int], + message: str, + discount_percent: int | None = None, + idempotency_key: str | None = None, ) -> MultiCreateSpecialOfferResult: """Создает рассылку спецпредложений.""" - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", "/special-offers/v1/multiCreate", - context=RequestContext("messenger.special_offers.create_multi", allow_retry=True), - json_body=request.to_payload(), + context=RequestContext( + "messenger.special_offers.create_multi", + allow_retry=idempotency_key is not None, + ), + mapper=map_multi_create_result, + json_body=MultiCreateSpecialOfferRequest( + item_ids=item_ids, + message=message, + discount_percent=discount_percent, + ).to_payload(), + idempotency_key=idempotency_key, ) - return map_multi_create_result(payload) - def confirm_multi(self, request: MultiConfirmSpecialOfferRequest) -> WebhookActionResult: + def confirm_multi( + self, *, campaign_id: str, idempotency_key: str | None = None + ) -> WebhookActionResult: """Подтверждает и оплачивает рассылку.""" - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", "/special-offers/v1/multiConfirm", - context=RequestContext("messenger.special_offers.confirm_multi", allow_retry=True), - json_body=request.to_payload(), + context=RequestContext( + "messenger.special_offers.confirm_multi", + allow_retry=idempotency_key is not None, + ), + mapper=map_webhook_action, + json_body=MultiConfirmSpecialOfferRequest(campaign_id=campaign_id).to_payload(), + idempotency_key=idempotency_key, ) - return map_webhook_action(payload) - def get_stats(self, request: SpecialOfferStatsRequest) -> SpecialOfferStatsResult: + def get_stats(self, *, campaign_id: str) -> SpecialOfferStatsResult: """Получает статистику рассылки.""" - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", "/special-offers/v1/stats", context=RequestContext("messenger.special_offers.get_stats", allow_retry=True), - json_body=request.to_payload(), + mapper=map_special_offer_stats, + json_body=SpecialOfferStatsRequest(campaign_id=campaign_id).to_payload(), ) - return map_special_offer_stats(payload) def get_tariff_info(self) -> TariffInfo: """Получает информацию о тарифе спецпредложений.""" - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", "/special-offers/v1/tariffInfo", context=RequestContext("messenger.special_offers.get_tariff_info", allow_retry=True), + mapper=map_tariff_info, ) - return map_tariff_info(payload) __all__ = ("MediaClient", "MessengerClient", "SpecialOffersClient", "WebhookClient") diff --git a/avito/messenger/domain.py b/avito/messenger/domain.py index 80211ae..6a5b97f 100644 --- a/avito/messenger/domain.py +++ b/avito/messenger/domain.py @@ -8,26 +8,16 @@ from avito.core.domain import DomainObject from avito.messenger.client import MediaClient, MessengerClient, SpecialOffersClient, WebhookClient from avito.messenger.models import ( - BlacklistRequest, ChatInfo, ChatsResult, MessageActionResult, MessagesResult, - MultiConfirmSpecialOfferRequest, - MultiCreateSpecialOfferRequest, MultiCreateSpecialOfferResult, - SendImageMessageRequest, - SendMessageRequest, - SpecialOfferAvailableRequest, SpecialOfferAvailableResult, - SpecialOfferStatsRequest, SpecialOfferStatsResult, SubscriptionsResult, TariffInfo, - UnsubscribeWebhookRequest, - UpdateWebhookRequest, UploadImageFile, - UploadImagesRequest, UploadImagesResult, VoiceFilesResult, WebhookActionResult, @@ -54,20 +44,27 @@ def list(self) -> ChatsResult: return MessengerClient(self.transport).list_chats(user_id=self._require_user_id()) - def mark_read(self) -> MessageActionResult: + def mark_read(self, *, idempotency_key: str | None = None) -> MessageActionResult: """Помечает чат как прочитанный.""" return MessengerClient(self.transport).read_chat( user_id=self._require_user_id(), chat_id=self._require_chat_id(), + idempotency_key=idempotency_key, ) - def blacklist(self, *, blacklisted_user_id: int) -> MessageActionResult: + def blacklist( + self, + *, + blacklisted_user_id: int, + idempotency_key: str | None = None, + ) -> MessageActionResult: """Добавляет пользователя в blacklist.""" return MessengerClient(self.transport).add_to_blacklist( user_id=self._require_user_id(), - request=BlacklistRequest(blacklisted_user_id=blacklisted_user_id), + blacklisted_user_id=blacklisted_user_id, + idempotency_key=idempotency_key, ) def _require_user_id(self) -> int: @@ -97,28 +94,46 @@ def list(self, *, chat_id: str | None = None) -> MessagesResult: chat_id=chat_id or self._require_chat_id(), ) - def send_message(self, *, chat_id: str | None = None, message: str) -> MessageActionResult: + def send_message( + self, + *, + chat_id: str | None = None, + message: str, + idempotency_key: str | None = None, + ) -> MessageActionResult: """Отправляет текстовое сообщение.""" return MessengerClient(self.transport).send_message( user_id=self._require_user_id(), chat_id=chat_id or self._require_chat_id(), - request=SendMessageRequest(message=message), + message=message, + idempotency_key=idempotency_key, ) def send_image( - self, *, chat_id: str | None = None, image_id: str, caption: str | None = None + self, + *, + chat_id: str | None = None, + image_id: str, + caption: str | None = None, + idempotency_key: str | None = None, ) -> MessageActionResult: """Отправляет сообщение с изображением.""" return MessengerClient(self.transport).send_image_message( user_id=self._require_user_id(), chat_id=chat_id or self._require_chat_id(), - request=SendImageMessageRequest(image_id=image_id, caption=caption), + image_id=image_id, + caption=caption, + idempotency_key=idempotency_key, ) def delete( - self, *, chat_id: str | None = None, message_id: str | None = None + self, + *, + chat_id: str | None = None, + message_id: str | None = None, + idempotency_key: str | None = None, ) -> MessageActionResult: """Удаляет сообщение.""" @@ -127,6 +142,7 @@ def delete( user_id=self._require_user_id(), chat_id=chat_id or self._require_chat_id(), message_id=resolved_message_id, + idempotency_key=idempotency_key, ) def _require_user_id(self) -> int: @@ -156,15 +172,25 @@ def list(self) -> SubscriptionsResult: return WebhookClient(self.transport).get_subscriptions() - def unsubscribe(self, *, url: str) -> WebhookActionResult: + def unsubscribe(self, *, url: str, idempotency_key: str | None = None) -> WebhookActionResult: """Отключает webhook.""" - return WebhookClient(self.transport).unsubscribe(UnsubscribeWebhookRequest(url=url)) + return WebhookClient(self.transport).unsubscribe(url=url, idempotency_key=idempotency_key) - def subscribe(self, *, url: str, secret: str | None = None) -> WebhookActionResult: + def subscribe( + self, + *, + url: str, + secret: str | None = None, + idempotency_key: str | None = None, + ) -> WebhookActionResult: """Включает webhook v3.""" - return WebhookClient(self.transport).update_v3(UpdateWebhookRequest(url=url, secret=secret)) + return WebhookClient(self.transport).update_v3( + url=url, + secret=secret, + idempotency_key=idempotency_key, + ) @dataclass(slots=True, frozen=True) @@ -178,12 +204,18 @@ def get_voice_files(self) -> VoiceFilesResult: return MediaClient(self.transport).get_voice_files(user_id=self._require_user_id()) - def upload_images(self, *, files: list[UploadImageFile]) -> UploadImagesResult: + def upload_images( + self, + *, + files: list[UploadImageFile], + idempotency_key: str | None = None, + ) -> UploadImagesResult: """Загружает изображения для сообщений.""" return MediaClient(self.transport).upload_images( user_id=self._require_user_id(), - request=UploadImagesRequest(files=files), + files=files, + idempotency_key=idempotency_key, ) def _require_user_id(self) -> int: @@ -202,35 +234,43 @@ class SpecialOfferCampaign(DomainObject): def get_available(self, *, item_ids: list[int]) -> SpecialOfferAvailableResult: """Получает объявления, доступные для рассылки.""" - return SpecialOffersClient(self.transport).get_available( - SpecialOfferAvailableRequest(item_ids=item_ids) - ) + return SpecialOffersClient(self.transport).get_available(item_ids=item_ids) def create_multi( - self, *, item_ids: list[int], message: str, discount_percent: int | None = None + self, + *, + item_ids: list[int], + message: str, + discount_percent: int | None = None, + idempotency_key: str | None = None, ) -> MultiCreateSpecialOfferResult: """Создает рассылку спецпредложений.""" return SpecialOffersClient(self.transport).create_multi( - MultiCreateSpecialOfferRequest( - item_ids=item_ids, - message=message, - discount_percent=discount_percent, - ) + item_ids=item_ids, + message=message, + discount_percent=discount_percent, + idempotency_key=idempotency_key, ) - def confirm_multi(self, *, campaign_id: str | None = None) -> WebhookActionResult: + def confirm_multi( + self, + *, + campaign_id: str | None = None, + idempotency_key: str | None = None, + ) -> WebhookActionResult: """Подтверждает и оплачивает рассылку.""" return SpecialOffersClient(self.transport).confirm_multi( - MultiConfirmSpecialOfferRequest(campaign_id=campaign_id or self._require_campaign_id()) + campaign_id=campaign_id or self._require_campaign_id(), + idempotency_key=idempotency_key, ) def get_stats(self, *, campaign_id: str | None = None) -> SpecialOfferStatsResult: """Получает статистику рассылки.""" return SpecialOffersClient(self.transport).get_stats( - SpecialOfferStatsRequest(campaign_id=campaign_id or self._require_campaign_id()) + campaign_id=campaign_id or self._require_campaign_id() ) def get_tariff_info(self) -> TariffInfo: diff --git a/avito/messenger/enums.py b/avito/messenger/enums.py new file mode 100644 index 0000000..3073d25 --- /dev/null +++ b/avito/messenger/enums.py @@ -0,0 +1,62 @@ +"""Enum-значения раздела messenger.""" + +from __future__ import annotations + +from enum import Enum + + +class MessageDirection(str, Enum): + """Направление сообщения.""" + + UNKNOWN = "__unknown__" + IN = "in" + OUT = "out" + + +class MessageType(str, Enum): + """Тип сообщения.""" + + UNKNOWN = "__unknown__" + TEXT = "text" + IMAGE = "image" + + +class MessageActionStatus(str, Enum): + """Статус операции с сообщением или чатом.""" + + UNKNOWN = "__unknown__" + SENT = "sent" + CONFIRMED = "confirmed" + + +class SubscriptionStatus(str, Enum): + """Статус webhook-подписки.""" + + UNKNOWN = "__unknown__" + ACTIVE = "active" + + +class WebhookStatus(str, Enum): + """Статус действия с webhook.""" + + UNKNOWN = "__unknown__" + SUBSCRIBED = "subscribed" + CONFIRMED = "confirmed" + + +class SpecialOfferCampaignStatus(str, Enum): + """Статус кампании спецпредложений.""" + + UNKNOWN = "__unknown__" + DRAFT = "draft" + CONFIRMED = "confirmed" + + +__all__ = ( + "MessageActionStatus", + "MessageDirection", + "MessageType", + "SpecialOfferCampaignStatus", + "SubscriptionStatus", + "WebhookStatus", +) diff --git a/avito/messenger/mappers.py b/avito/messenger/mappers.py index b8ad350..f15a8c9 100644 --- a/avito/messenger/mappers.py +++ b/avito/messenger/mappers.py @@ -6,7 +6,16 @@ from datetime import datetime from typing import cast +from avito.core.enums import map_enum_or_unknown from avito.core.exceptions import ResponseMappingError +from avito.messenger.enums import ( + MessageActionStatus, + MessageDirection, + MessageType, + SpecialOfferCampaignStatus, + SubscriptionStatus, + WebhookStatus, +) from avito.messenger.models import ( ChatInfo, ChatsResult, @@ -127,8 +136,16 @@ def map_message(payload: object) -> MessageInfo: author_id=_int(data, "author_id", "authorId", "user_id", "userId"), text=_str(data, "text", "message"), created_at=_datetime(data, "created_at", "createdAt"), - direction=_str(data, "direction"), - type=_str(data, "type"), + direction=map_enum_or_unknown( + _str(data, "direction"), + MessageDirection, + enum_name="messenger.message_direction", + ), + type=map_enum_or_unknown( + _str(data, "type"), + MessageType, + enum_name="messenger.message_type", + ), ) @@ -149,7 +166,11 @@ def map_message_action(payload: object) -> MessageActionResult: return MessageActionResult( success=bool(data.get("success", True)), message_id=_str(data, "message_id", "messageId", "id"), - status=_str(data, "status", "message"), + status=map_enum_or_unknown( + _str(data, "status", "message"), + MessageActionStatus, + enum_name="messenger.message_action_status", + ), ) @@ -194,7 +215,11 @@ def map_subscriptions(payload: object) -> SubscriptionsResult: SubscriptionInfo( url=_str(item, "url"), version=_str(item, "version"), - status=_str(item, "status"), + status=map_enum_or_unknown( + _str(item, "status"), + SubscriptionStatus, + enum_name="messenger.subscription_status", + ), ) for item in _list(data, "subscriptions", "items", "result") ], @@ -207,7 +232,11 @@ def map_webhook_action(payload: object) -> WebhookActionResult: data = _expect_mapping(payload) return WebhookActionResult( success=bool(data.get("success", True)), - status=_str(data, "status", "message"), + status=map_enum_or_unknown( + _str(data, "status", "message"), + WebhookStatus, + enum_name="messenger.webhook_status", + ), ) @@ -233,7 +262,11 @@ def map_multi_create_result(payload: object) -> MultiCreateSpecialOfferResult: data = _expect_mapping(payload) return MultiCreateSpecialOfferResult( campaign_id=_str(data, "campaign_id", "campaignId", "id"), - status=_str(data, "status"), + status=map_enum_or_unknown( + _str(data, "status"), + SpecialOfferCampaignStatus, + enum_name="messenger.special_offer_campaign_status", + ), ) diff --git a/avito/messenger/models.py b/avito/messenger/models.py index 2464512..823d5f7 100644 --- a/avito/messenger/models.py +++ b/avito/messenger/models.py @@ -7,6 +7,14 @@ from typing import BinaryIO from avito.core.serialization import SerializableModel +from avito.messenger.enums import ( + MessageActionStatus, + MessageDirection, + MessageType, + SpecialOfferCampaignStatus, + SubscriptionStatus, + WebhookStatus, +) @dataclass(slots=True, frozen=True) @@ -33,7 +41,7 @@ class SendMessageRequest: """Запрос отправки текстового сообщения.""" message: str - type: str | None = None + type: MessageType | None = None def to_payload(self) -> dict[str, object]: """Сериализует сообщение для API.""" @@ -71,8 +79,8 @@ class MessageInfo(SerializableModel): author_id: int | None text: str | None created_at: datetime | None - direction: str | None - type: str | None + direction: MessageDirection | None + type: MessageType | None @dataclass(slots=True, frozen=True) @@ -89,7 +97,7 @@ class MessageActionResult(SerializableModel): success: bool message_id: str | None = None - status: str | None = None + status: MessageActionStatus | None = None @dataclass(slots=True, frozen=True) @@ -154,7 +162,7 @@ class SubscriptionInfo(SerializableModel): url: str | None version: str | None - status: str | None + status: SubscriptionStatus | None @dataclass(slots=True, frozen=True) @@ -198,7 +206,7 @@ class WebhookActionResult(SerializableModel): """Результат операции с webhook.""" success: bool - status: str | None = None + status: WebhookStatus | None = None @dataclass(slots=True, frozen=True) @@ -268,7 +276,7 @@ class MultiCreateSpecialOfferResult(SerializableModel): """Результат создания рассылки.""" campaign_id: str | None - status: str | None + status: SpecialOfferCampaignStatus | None @dataclass(slots=True, frozen=True) diff --git a/avito/orders/__init__.py b/avito/orders/__init__.py index e1a304e..260d1b2 100644 --- a/avito/orders/__init__.py +++ b/avito/orders/__init__.py @@ -8,6 +8,13 @@ SandboxDelivery, Stock, ) +from avito.orders.enums import ( + DeliveryStatus, + LabelTaskStatus, + OrderStatus, + TrackingAvitoEventType, + TrackingAvitoStatus, +) from avito.orders.models import ( AddSortingCentersRequest, AddTariffV2Request, @@ -91,6 +98,7 @@ "DeliveryAnnouncementRequest", "DeliveryDateInterval", "DeliveryEntityResult", + "DeliveryStatus", "DeliveryOrder", "DeliveryParcelIdsRequest", "DeliveryParcelRequest", @@ -101,6 +109,7 @@ "DeliveryDirection", "DeliveryDirectionZone", "LabelPdfResult", + "LabelTaskStatus", "LabelTaskResult", "Order", "OrderDeliveryProperties", @@ -113,6 +122,7 @@ "OrderLabelsRequest", "OrderLabel", "OrderMarkingsRequest", + "OrderStatus", "OrderTrackingNumberRequest", "OrdersResult", "ProhibitOrderAcceptanceRequest", @@ -139,6 +149,8 @@ "DeliveryTermsZone", "UpdateTermsRequest", "DeliveryTrackingOptions", + "TrackingAvitoEventType", + "TrackingAvitoStatus", "DeliveryTrackingRequest", "DeliveryTerms", "CancelParcelRequest", diff --git a/avito/orders/client.py b/avito/orders/client.py index 06d3301..688684b 100644 --- a/avito/orders/client.py +++ b/avito/orders/client.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from avito.core import RequestContext, Transport +from avito.orders.enums import TrackingAvitoEventType, TrackingAvitoStatus from avito.orders.mappers import ( map_courier_ranges, map_delivery_entity, @@ -21,17 +22,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, @@ -44,92 +53,136 @@ 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, ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class OrdersClient: """Выполняет HTTP-операции управления заказами.""" transport: Transport def list_orders(self) -> OrdersResult: - payload = self.transport.request_json( + return self.transport.request_public_model( "GET", "/order-management/1/orders", context=RequestContext("orders.list_orders"), + mapper=map_orders, ) - return map_orders(payload) - def update_markings(self, request: OrderMarkingsRequest) -> OrderActionResult: - return self._post_action("/order-management/1/markings", "orders.update_markings", request) + def update_markings( + self, *, order_id: str, codes: list[str], idempotency_key: str | None = None + ) -> OrderActionResult: + return self._post_action( + "/order-management/1/markings", + "orders.update_markings", + OrderMarkingsRequest(order_id=order_id, codes=codes), + idempotency_key=idempotency_key, + ) - def accept_return_order(self, request: OrderAcceptReturnRequest) -> OrderActionResult: + def accept_return_order( + self, + *, + order_id: str, + postal_office_id: str, + idempotency_key: str | None = None, + ) -> OrderActionResult: return self._post_action( "/order-management/1/order/acceptReturnOrder", "orders.accept_return_order", - request, + OrderAcceptReturnRequest(order_id=order_id, postal_office_id=postal_office_id), + idempotency_key=idempotency_key, ) - def apply_transition(self, request: OrderApplyTransitionRequest) -> OrderActionResult: + def apply_transition( + self, *, order_id: str, transition: str, idempotency_key: str | None = None + ) -> OrderActionResult: return self._post_action( "/order-management/1/order/applyTransition", "orders.apply_transition", - request, + OrderApplyTransitionRequest(order_id=order_id, transition=transition), + idempotency_key=idempotency_key, ) - def check_confirmation_code(self, request: OrderConfirmationCodeRequest) -> OrderActionResult: + def check_confirmation_code( + self, *, order_id: str, code: str, idempotency_key: str | None = None + ) -> OrderActionResult: return self._post_action( "/order-management/1/order/checkConfirmationCode", "orders.check_confirmation_code", - request, + OrderConfirmationCodeRequest(order_id=order_id, code=code), + idempotency_key=idempotency_key, ) - def set_cnc_details(self, request: OrderCncDetailsRequest) -> OrderActionResult: + def set_cnc_details( + self, *, order_id: str, pickup_point_id: str, idempotency_key: str | None = None + ) -> OrderActionResult: return self._post_action( "/order-management/1/order/cncSetDetails", "orders.set_cnc_details", - request, + OrderCncDetailsRequest(order_id=order_id, pickup_point_id=pickup_point_id), + idempotency_key=idempotency_key, ) def get_courier_delivery_range(self) -> CourierRangesResult: - payload = self.transport.request_json( + return self.transport.request_public_model( "GET", "/order-management/1/order/getCourierDeliveryRange", context=RequestContext("orders.get_courier_delivery_range"), + mapper=map_courier_ranges, ) - return map_courier_ranges(payload) - def set_courier_delivery_range(self, request: OrderCourierRangeRequest) -> OrderActionResult: + def set_courier_delivery_range( + self, *, order_id: str, interval_id: str, idempotency_key: str | None = None + ) -> OrderActionResult: return self._post_action( "/order-management/1/order/setCourierDeliveryRange", "orders.set_courier_delivery_range", - request, + OrderCourierRangeRequest(order_id=order_id, interval_id=interval_id), + idempotency_key=idempotency_key, ) - def set_tracking_number(self, request: OrderTrackingNumberRequest) -> OrderActionResult: + def set_tracking_number( + self, + *, + order_id: str, + tracking_number: str, + idempotency_key: str | None = None, + ) -> OrderActionResult: return self._post_action( "/order-management/1/order/setTrackingNumber", "orders.set_tracking_number", - request, + OrderTrackingNumberRequest(order_id=order_id, tracking_number=tracking_number), + idempotency_key=idempotency_key, ) def _post_action( @@ -143,30 +196,42 @@ def _post_action( | OrderCncDetailsRequest | OrderCourierRangeRequest | OrderTrackingNumberRequest, + idempotency_key: str | None = None, ) -> OrderActionResult: - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", path, - context=RequestContext(operation, allow_retry=True), + context=RequestContext(operation, allow_retry=idempotency_key is not None), + mapper=map_order_action, json_body=request.to_payload(), + idempotency_key=idempotency_key, ) - return map_order_action(payload) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class LabelsClient: """Выполняет операции генерации и загрузки PDF-этикеток.""" transport: Transport - def create_generate_labels(self, request: OrderLabelsRequest) -> LabelTaskResult: - return self._create("/order-management/1/orders/labels", "orders.labels.create", request) + def create_generate_labels( + self, *, order_ids: list[str], idempotency_key: str | None = None + ) -> LabelTaskResult: + return self._create( + "/order-management/1/orders/labels", + "orders.labels.create", + OrderLabelsRequest(order_ids=order_ids), + idempotency_key=idempotency_key, + ) - def create_generate_labels_extended(self, request: OrderLabelsRequest) -> LabelTaskResult: + def create_generate_labels_extended( + self, *, order_ids: list[str], idempotency_key: str | None = None + ) -> LabelTaskResult: return self._create( "/order-management/1/orders/labels/extended", "orders.labels.create_extended", - request, + OrderLabelsRequest(order_ids=order_ids), + idempotency_key=idempotency_key, ) def get_download_label(self, *, task_id: str) -> LabelPdfResult: @@ -176,41 +241,81 @@ def get_download_label(self, *, task_id: str) -> LabelPdfResult: ) return LabelPdfResult(binary=binary) - def _create(self, path: str, operation: str, request: OrderLabelsRequest) -> LabelTaskResult: - payload = self.transport.request_json( + def _create( + self, + path: str, + operation: str, + request: OrderLabelsRequest, + idempotency_key: str | None = None, + ) -> LabelTaskResult: + return self.transport.request_public_model( "POST", path, - context=RequestContext(operation, allow_retry=True), + context=RequestContext(operation, allow_retry=idempotency_key is not None), + mapper=map_label_task, json_body=request.to_payload(), + idempotency_key=idempotency_key, ) - return map_label_task(payload) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class DeliveryClient: """Выполняет production-операции доставки.""" transport: Transport - def create_announcement(self, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: - return self._post("/createAnnouncement", "orders.delivery.create_announcement", request) + def create_announcement( + self, *, order_id: str, idempotency_key: str | None = None + ) -> DeliveryEntityResult: + return self._post( + "/createAnnouncement", + "orders.delivery.create_announcement", + DeliveryAnnouncementRequest(order_id=order_id), + idempotency_key=idempotency_key, + ) - def cancel_announcement(self, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: - return self._post("/cancelAnnouncement", "orders.delivery.cancel_announcement", request) + def cancel_announcement( + self, *, order_id: str, idempotency_key: str | None = None + ) -> DeliveryEntityResult: + return self._post( + "/cancelAnnouncement", + "orders.delivery.cancel_announcement", + DeliveryAnnouncementRequest(order_id=order_id), + idempotency_key=idempotency_key, + ) - def create_parcel(self, request: DeliveryParcelRequest) -> DeliveryEntityResult: - return self._post("/createParcel", "orders.delivery.create_parcel", request) + def create_parcel( + self, + *, + order_id: str, + parcel_id: str, + idempotency_key: str | None = None, + ) -> DeliveryEntityResult: + return self._post( + "/createParcel", + "orders.delivery.create_parcel", + DeliveryParcelRequest(order_id=order_id, parcel_id=parcel_id), + idempotency_key=idempotency_key, + ) - def change_parcel_result(self, request: DeliveryParcelResultRequest) -> DeliveryEntityResult: + def change_parcel_result( + self, *, parcel_id: str, result: str, idempotency_key: str | None = None + ) -> DeliveryEntityResult: return self._post( "/delivery/order/changeParcelResult", "orders.delivery.change_parcel_result", - request, + DeliveryParcelResultRequest(parcel_id=parcel_id, result=result), + idempotency_key=idempotency_key, ) - def update_change_parcels(self, request: DeliveryParcelIdsRequest) -> DeliveryEntityResult: + def update_change_parcels( + self, *, parcel_ids: list[str], idempotency_key: str | None = None + ) -> DeliveryEntityResult: return self._post( - "/sandbox/changeParcels", "orders.delivery.update_change_parcels", request + "/sandbox/changeParcels", + "orders.delivery.update_change_parcels", + DeliveryParcelIdsRequest(parcel_ids=parcel_ids), + idempotency_key=idempotency_key, ) def _post( @@ -221,196 +326,375 @@ def _post( | DeliveryParcelRequest | DeliveryParcelResultRequest | DeliveryParcelIdsRequest, + idempotency_key: str | None = None, ) -> DeliveryEntityResult: - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", path, - context=RequestContext(operation, allow_retry=True), + context=RequestContext(operation, allow_retry=idempotency_key is not None), + mapper=map_delivery_entity, json_body=request.to_payload(), + idempotency_key=idempotency_key, ) - return map_delivery_entity(payload) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class SandboxDeliveryClient: """Выполняет sandbox-операции доставки.""" transport: Transport - def create_announcement(self, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: + def create_announcement( + self, *, order_id: str, idempotency_key: str | None = None + ) -> DeliveryEntityResult: return self._post( - "/delivery-sandbox/announcements/create", "orders.sandbox.create_announcement", request + "/delivery-sandbox/announcements/create", + "orders.sandbox.create_announcement", + DeliveryAnnouncementRequest(order_id=order_id), + idempotency_key=idempotency_key, ) - def track_announcement(self, request: DeliveryAnnouncementRequest) -> DeliveryEntityResult: + def track_announcement( + self, *, order_id: str, idempotency_key: str | None = None + ) -> DeliveryEntityResult: return self._post( - "/delivery-sandbox/announcements/track", "orders.sandbox.track_announcement", request + "/delivery-sandbox/announcements/track", + "orders.sandbox.track_announcement", + DeliveryAnnouncementRequest(order_id=order_id), + idempotency_key=idempotency_key, ) def update_custom_area_schedule( - self, request: CustomAreaScheduleRequest + self, *, items: list[CustomAreaScheduleEntry], idempotency_key: str | None = None ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/areas/custom-schedule", "orders.sandbox.update_custom_area_schedule", - request, + CustomAreaScheduleRequest(items=items), + idempotency_key=idempotency_key, ) - def cancel_parcel(self, request: CancelParcelRequest) -> DeliveryEntityResult: - return self._post("/delivery-sandbox/cancelParcel", "orders.sandbox.cancel_parcel", request) + def cancel_parcel( + self, *, parcel_id: str, actor: str, idempotency_key: str | None = None + ) -> DeliveryEntityResult: + return self._post( + "/delivery-sandbox/cancelParcel", + "orders.sandbox.cancel_parcel", + CancelParcelRequest(parcel_id=parcel_id, actor=actor), + idempotency_key=idempotency_key, + ) def check_confirmation_code( - self, request: SandboxConfirmationCodeRequest + self, *, parcel_id: str, confirm_code: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/order/checkConfirmationCode", "orders.sandbox.check_confirmation_code", - request, + SandboxConfirmationCodeRequest(parcel_id=parcel_id, confirm_code=confirm_code), + idempotency_key=idempotency_key, ) - def set_order_properties(self, request: SetOrderPropertiesRequest) -> DeliveryEntityResult: + def set_order_properties( + self, + *, + order_id: str, + properties: OrderDeliveryProperties, + idempotency_key: str | None = None, + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/order/properties", "orders.sandbox.set_order_properties", - request, + SetOrderPropertiesRequest(order_id=order_id, properties=properties), + idempotency_key=idempotency_key, ) - def set_order_real_address(self, request: SetOrderRealAddressRequest) -> DeliveryEntityResult: + def set_order_real_address( + self, + *, + order_id: str, + address: RealAddress, + idempotency_key: str | None = None, + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/order/realAddress", "orders.sandbox.set_order_real_address", - request, + SetOrderRealAddressRequest(order_id=order_id, address=address), + idempotency_key=idempotency_key, ) - def tracking(self, request: DeliveryTrackingRequest) -> DeliveryEntityResult: - return self._post("/delivery-sandbox/order/tracking", "orders.sandbox.tracking", request) + def tracking( + self, + *, + order_id: str, + avito_status: TrackingAvitoStatus | str, + avito_event_type: TrackingAvitoEventType | str, + provider_event_code: str, + date: str, + location: str, + comment: str | None = None, + options: DeliveryTrackingOptions | None = None, + idempotency_key: str | None = None, + ) -> DeliveryEntityResult: + return self._post( + "/delivery-sandbox/order/tracking", + "orders.sandbox.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, + ), + idempotency_key=idempotency_key, + ) def prohibit_order_acceptance( - self, request: ProhibitOrderAcceptanceRequest + self, *, order_id: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/prohibitOrderAcceptance", "orders.sandbox.prohibit_order_acceptance", - request, + ProhibitOrderAcceptanceRequest(order_id=order_id), + idempotency_key=idempotency_key, ) def list_sorting_center(self) -> DeliverySortingCentersResult: - payload = self.transport.request_json( + return self.transport.request_public_model( "GET", "/delivery-sandbox/sorting-center", context=RequestContext("orders.sandbox.list_sorting_center"), + mapper=map_sorting_centers, ) - return map_sorting_centers(payload) - def add_sorting_center(self, request: AddSortingCentersRequest) -> DeliveryEntityResult: + def add_sorting_center( + self, *, items: list[SortingCenterUpload], idempotency_key: str | None = None + ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/tariffs/sorting-center", "orders.sandbox.add_sorting_center", - request, + AddSortingCentersRequest(items=items), + idempotency_key=idempotency_key, ) - def add_areas(self, *, tariff_id: str, request: SandboxAreasRequest) -> DeliveryEntityResult: + def add_areas( + self, + *, + tariff_id: str, + areas: list[SandboxArea], + idempotency_key: str | None = None, + ) -> DeliveryEntityResult: return self._post( f"/delivery-sandbox/tariffs/{tariff_id}/areas", "orders.sandbox.add_areas", - request, + SandboxAreasRequest(areas=areas), + idempotency_key=idempotency_key, ) def add_tags_to_sorting_center( - self, *, tariff_id: str, request: TaggedSortingCentersRequest + self, + *, + tariff_id: str, + items: list[TaggedSortingCenter], + idempotency_key: str | None = None, ) -> DeliveryEntityResult: return self._post( f"/delivery-sandbox/tariffs/{tariff_id}/tagged-sorting-centers", "orders.sandbox.add_tags_to_sorting_center", - request, + TaggedSortingCentersRequest(items=items), + idempotency_key=idempotency_key, ) def add_terminals( - self, *, tariff_id: str, request: AddTerminalsRequest + self, + *, + tariff_id: str, + items: list[TerminalUpload], + idempotency_key: str | None = None, ) -> DeliveryEntityResult: return self._post( f"/delivery-sandbox/tariffs/{tariff_id}/terminals", "orders.sandbox.add_terminals", - request, + AddTerminalsRequest(items=items), + idempotency_key=idempotency_key, ) - def update_terms(self, *, tariff_id: str, request: UpdateTermsRequest) -> DeliveryEntityResult: + def update_terms( + self, + *, + tariff_id: str, + items: list[DeliveryTermsZone], + idempotency_key: str | None = None, + ) -> DeliveryEntityResult: return self._post( f"/delivery-sandbox/tariffs/{tariff_id}/terms", "orders.sandbox.update_terms", - request, + UpdateTermsRequest(items=items), + idempotency_key=idempotency_key, ) - def add_tariff(self, request: AddTariffV2Request) -> DeliveryEntityResult: - return self._post("/delivery-sandbox/tariffsV2", "orders.sandbox.add_tariff", request) + def add_tariff( + self, + *, + name: str, + delivery_provider_tariff_id: str, + directions: list[DeliveryDirection], + tariff_zones: list[DeliveryTariffZone], + terms_zones: list[DeliveryTermsZone], + tariff_type: str | None = None, + idempotency_key: str | None = None, + ) -> DeliveryEntityResult: + return self._post( + "/delivery-sandbox/tariffsV2", + "orders.sandbox.add_tariff", + AddTariffV2Request( + name=name, + delivery_provider_tariff_id=delivery_provider_tariff_id, + directions=directions, + tariff_zones=tariff_zones, + terms_zones=terms_zones, + tariff_type=tariff_type, + ), + idempotency_key=idempotency_key, + ) def cancel_sandbox_announcement( - self, request: SandboxCancelAnnouncementRequest + self, + *, + announcement_id: str, + date: str, + options: SandboxCancelAnnouncementOptions, + idempotency_key: str | None = None, ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/cancelAnnouncement", "orders.sandbox.cancel_sandbox_announcement", - request, + SandboxCancelAnnouncementRequest( + announcement_id=announcement_id, + date=date, + options=options, + ), + idempotency_key=idempotency_key, ) - def cancel_sandbox_parcel(self, request: CancelSandboxParcelRequest) -> DeliveryEntityResult: + def cancel_sandbox_parcel( + self, + *, + parcel_id: str, + options: CancelSandboxParcelOptions | None = None, + idempotency_key: str | None = None, + ) -> DeliveryEntityResult: return self._post( - "/delivery-sandbox/v1/cancelParcel", "orders.sandbox.cancel_sandbox_parcel", request + "/delivery-sandbox/v1/cancelParcel", + "orders.sandbox.cancel_sandbox_parcel", + CancelSandboxParcelRequest(parcel_id=parcel_id, options=options), + idempotency_key=idempotency_key, ) - def change_sandbox_parcel(self, request: ChangeParcelRequest) -> DeliveryEntityResult: + def change_sandbox_parcel( + self, + *, + type: str, + parcel_id: str, + application: ChangeParcelApplication | None = None, + options: ChangeParcelOptions | None = None, + idempotency_key: str | None = None, + ) -> DeliveryEntityResult: return self._post( - "/delivery-sandbox/v1/changeParcel", "orders.sandbox.change_sandbox_parcel", request + "/delivery-sandbox/v1/changeParcel", + "orders.sandbox.change_sandbox_parcel", + ChangeParcelRequest( + type=type, + parcel_id=parcel_id, + application=application, + options=options, + ), + idempotency_key=idempotency_key, ) def create_sandbox_announcement( - self, request: SandboxCreateAnnouncementRequest + self, + *, + announcement_id: str, + barcode: str, + sender: SandboxAnnouncementParticipant, + receiver: SandboxAnnouncementParticipant, + announcement_type: str, + date: str, + packages: list[SandboxAnnouncementPackage], + options: SandboxCreateAnnouncementOptions, + idempotency_key: str | None = None, ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/createAnnouncement", "orders.sandbox.create_sandbox_announcement", - request, + SandboxCreateAnnouncementRequest( + announcement_id=announcement_id, + barcode=barcode, + sender=sender, + receiver=receiver, + announcement_type=announcement_type, + date=date, + packages=packages, + options=options, + ), + idempotency_key=idempotency_key, ) def get_sandbox_announcement_event( - self, request: SandboxGetAnnouncementEventRequest + self, *, announcement_id: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/getAnnouncementEvent", "orders.sandbox.get_sandbox_announcement_event", - request, + SandboxGetAnnouncementEventRequest(announcement_id=announcement_id), + idempotency_key=idempotency_key, ) def get_sandbox_change_parcel_info( - self, request: GetChangeParcelInfoRequest + self, *, application_id: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/getChangeParcelInfo", "orders.sandbox.get_sandbox_change_parcel_info", - request, + GetChangeParcelInfoRequest(application_id=application_id), + idempotency_key=idempotency_key, ) def get_sandbox_parcel_info( - self, request: GetSandboxParcelInfoRequest + self, *, parcel_id: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/getParcelInfo", "orders.sandbox.get_sandbox_parcel_info", - request, + GetSandboxParcelInfoRequest(parcel_id=parcel_id), + idempotency_key=idempotency_key, ) def get_sandbox_registered_parcel_id( - self, request: GetRegisteredParcelIdRequest + self, *, order_id: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: return self._post( "/delivery-sandbox/v1/getRegisteredParcelID", "orders.sandbox.get_sandbox_registered_parcel_id", - request, + GetRegisteredParcelIdRequest(order_id=order_id), + idempotency_key=idempotency_key, ) - def create_parcel(self, request: DeliveryParcelRequest) -> DeliveryEntityResult: + def create_parcel( + self, + *, + order_id: str, + parcel_id: str, + idempotency_key: str | None = None, + ) -> DeliveryEntityResult: return self._post( - "/delivery-sandbox/v2/createParcel", "orders.sandbox.create_parcel", request + "/delivery-sandbox/v2/createParcel", + "orders.sandbox.create_parcel", + DeliveryParcelRequest(order_id=order_id, parcel_id=parcel_id), + idempotency_key=idempotency_key, ) def _post( @@ -440,54 +724,65 @@ def _post( | GetSandboxParcelInfoRequest | GetRegisteredParcelIdRequest | DeliveryParcelRequest, + idempotency_key: str | None = None, ) -> DeliveryEntityResult: - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", path, - context=RequestContext(operation, allow_retry=True), + context=RequestContext(operation, allow_retry=idempotency_key is not None), + mapper=map_delivery_entity, json_body=request.to_payload(), + idempotency_key=idempotency_key, ) - return map_delivery_entity(payload) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class DeliveryTasksClient: """Получает статус задач delivery API.""" transport: Transport def get_task(self, *, task_id: str) -> DeliveryTaskInfo: - payload = self.transport.request_json( + return self.transport.request_public_model( "GET", f"/delivery-sandbox/tasks/{task_id}", context=RequestContext("orders.delivery_task.get_task", allow_retry=True), + mapper=map_delivery_task, ) - return map_delivery_task(payload) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class StockManagementClient: """Выполняет операции управления остатками.""" transport: Transport - def get_info(self, request: StockInfoRequest) -> StockInfoResult: - payload = self.transport.request_json( + def get_info( + self, *, item_ids: list[int], idempotency_key: str | None = None + ) -> StockInfoResult: + return self.transport.request_public_model( "POST", "/stock-management/1/info", - context=RequestContext("orders.stock.get_info", allow_retry=True), - json_body=request.to_payload(), + context=RequestContext("orders.stock.get_info", allow_retry=idempotency_key is not None), + mapper=map_stock_info, + json_body=StockInfoRequest(item_ids=item_ids).to_payload(), + idempotency_key=idempotency_key, ) - return map_stock_info(payload) - def update_stocks(self, request: StockUpdateRequest) -> StockUpdateResult: - payload = self.transport.request_json( + def update_stocks( + self, *, stocks: list[StockUpdateEntry], idempotency_key: str | None = None + ) -> StockUpdateResult: + return self.transport.request_public_model( "PUT", "/stock-management/1/stocks", - context=RequestContext("orders.stock.update_stocks", allow_retry=True), - json_body=request.to_payload(), + context=RequestContext( + "orders.stock.update_stocks", + allow_retry=idempotency_key is not None, + ), + mapper=map_stock_update, + json_body=StockUpdateRequest(stocks=stocks).to_payload(), + idempotency_key=idempotency_key, ) - return map_stock_update(payload) __all__ = ( diff --git a/avito/orders/domain.py b/avito/orders/domain.py index 5f0e50c..678c0db 100644 --- a/avito/orders/domain.py +++ b/avito/orders/domain.py @@ -15,71 +15,37 @@ SandboxDeliveryClient, StockManagementClient, ) +from avito.orders.enums import TrackingAvitoEventType, TrackingAvitoStatus from avito.orders.models import ( - AddSortingCentersRequest, - 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, - GetSandboxParcelInfoRequest, LabelPdfResult, LabelTaskResult, - OrderAcceptReturnRequest, OrderActionResult, - OrderApplyTransitionRequest, - 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, ) @@ -92,42 +58,70 @@ class Order(DomainObject): def list(self) -> OrdersResult: return OrdersClient(self.transport).list_orders() - def update_markings(self, *, order_id: str, codes: Sequence[str]) -> OrderActionResult: + def update_markings( + self, *, order_id: str, codes: Sequence[str], idempotency_key: str | None = None + ) -> OrderActionResult: return OrdersClient(self.transport).update_markings( - OrderMarkingsRequest(order_id=order_id, codes=list(codes)) + order_id=order_id, + codes=list(codes), + idempotency_key=idempotency_key, ) - def accept_return_order(self, *, order_id: str, postal_office_id: str) -> OrderActionResult: + def accept_return_order( + self, *, order_id: str, postal_office_id: str, idempotency_key: str | None = None + ) -> OrderActionResult: return OrdersClient(self.transport).accept_return_order( - OrderAcceptReturnRequest(order_id=order_id, postal_office_id=postal_office_id) + order_id=order_id, + postal_office_id=postal_office_id, + idempotency_key=idempotency_key, ) - def apply(self, *, order_id: str, transition: str) -> OrderActionResult: + def apply( + self, *, order_id: str, transition: str, idempotency_key: str | None = None + ) -> OrderActionResult: return OrdersClient(self.transport).apply_transition( - OrderApplyTransitionRequest(order_id=order_id, transition=transition) + order_id=order_id, + transition=transition, + idempotency_key=idempotency_key, ) - def check_confirmation_code(self, *, order_id: str, code: str) -> OrderActionResult: + def check_confirmation_code( + self, *, order_id: str, code: str, idempotency_key: str | None = None + ) -> OrderActionResult: return OrdersClient(self.transport).check_confirmation_code( - OrderConfirmationCodeRequest(order_id=order_id, code=code) + order_id=order_id, + code=code, + idempotency_key=idempotency_key, ) - def set_cnc_details(self, *, order_id: str, pickup_point_id: str) -> OrderActionResult: + def set_cnc_details( + self, *, order_id: str, pickup_point_id: str, idempotency_key: str | None = None + ) -> OrderActionResult: return OrdersClient(self.transport).set_cnc_details( - OrderCncDetailsRequest(order_id=order_id, pickup_point_id=pickup_point_id) + order_id=order_id, + pickup_point_id=pickup_point_id, + idempotency_key=idempotency_key, ) def get_courier_delivery_range(self) -> CourierRangesResult: return OrdersClient(self.transport).get_courier_delivery_range() - def set_courier_delivery_range(self, *, order_id: str, interval_id: str) -> OrderActionResult: + def set_courier_delivery_range( + self, *, order_id: str, interval_id: str, idempotency_key: str | None = None + ) -> OrderActionResult: return OrdersClient(self.transport).set_courier_delivery_range( - OrderCourierRangeRequest(order_id=order_id, interval_id=interval_id) + order_id=order_id, + interval_id=interval_id, + idempotency_key=idempotency_key, ) - def update_tracking_number(self, *, order_id: str, tracking_number: str) -> OrderActionResult: + def update_tracking_number( + self, *, order_id: str, tracking_number: str, idempotency_key: str | None = None + ) -> OrderActionResult: return OrdersClient(self.transport).set_tracking_number( - OrderTrackingNumberRequest(order_id=order_id, tracking_number=tracking_number) + order_id=order_id, + tracking_number=tracking_number, + idempotency_key=idempotency_key, ) @@ -138,12 +132,23 @@ class OrderLabel(DomainObject): task_id: int | str | None = None user_id: int | str | None = None - def create(self, *, order_ids: Sequence[str], extended: bool = False) -> LabelTaskResult: + def create( + self, + *, + order_ids: Sequence[str], + extended: bool = False, + idempotency_key: str | None = None, + ) -> 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) + return client.create_generate_labels_extended( + order_ids=list(order_ids), + idempotency_key=idempotency_key, + ) + return client.create_generate_labels( + order_ids=list(order_ids), + idempotency_key=idempotency_key, + ) def download(self, *, task_id: str | None = None) -> LabelPdfResult: resolved_task_id = task_id or self._require_task_id() @@ -161,29 +166,50 @@ class DeliveryOrder(DomainObject): user_id: int | str | None = None - def create_announcement(self, *, order_id: str) -> DeliveryEntityResult: + def create_announcement( + self, *, order_id: str, idempotency_key: str | None = None + ) -> DeliveryEntityResult: return DeliveryClient(self.transport).create_announcement( - DeliveryAnnouncementRequest(order_id=order_id) + order_id=order_id, + idempotency_key=idempotency_key, ) - def delete(self, *, order_id: str) -> DeliveryEntityResult: + def delete( + self, *, order_id: str, idempotency_key: str | None = None + ) -> DeliveryEntityResult: return DeliveryClient(self.transport).cancel_announcement( - DeliveryAnnouncementRequest(order_id=order_id) + order_id=order_id, + idempotency_key=idempotency_key, ) - def create(self, *, order_id: str, parcel_id: str) -> DeliveryEntityResult: + def create( + self, + *, + order_id: str, + parcel_id: str, + idempotency_key: str | None = None, + ) -> DeliveryEntityResult: return DeliveryClient(self.transport).create_parcel( - DeliveryParcelRequest(order_id=order_id, parcel_id=parcel_id) + order_id=order_id, + parcel_id=parcel_id, + idempotency_key=idempotency_key, ) - def update_change_parcels(self, *, parcel_ids: Sequence[str]) -> DeliveryEntityResult: + def update_change_parcels( + self, *, parcel_ids: Sequence[str], idempotency_key: str | None = None + ) -> DeliveryEntityResult: return DeliveryClient(self.transport).update_change_parcels( - DeliveryParcelIdsRequest(parcel_ids=list(parcel_ids)) + parcel_ids=list(parcel_ids), + idempotency_key=idempotency_key, ) - def create_change_parcel_result(self, *, parcel_id: str, result: str) -> DeliveryEntityResult: + def create_change_parcel_result( + self, *, parcel_id: str, result: str, idempotency_key: str | None = None + ) -> DeliveryEntityResult: return DeliveryClient(self.transport).change_parcel_result( - DeliveryParcelResultRequest(parcel_id=parcel_id, result=result) + parcel_id=parcel_id, + result=result, + idempotency_key=idempotency_key, ) @@ -193,109 +219,164 @@ class SandboxDelivery(DomainObject): user_id: int | str | None = None - def create_announcement(self, *, order_id: str) -> DeliveryEntityResult: + def create_announcement( + self, *, order_id: str, idempotency_key: str | None = None + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).create_announcement( - DeliveryAnnouncementRequest(order_id=order_id) + order_id=order_id, + idempotency_key=idempotency_key, ) - def track_announcement(self, *, order_id: str) -> DeliveryEntityResult: + def track_announcement( + self, *, order_id: str, idempotency_key: str | None = None + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).track_announcement( - DeliveryAnnouncementRequest(order_id=order_id) + order_id=order_id, + idempotency_key=idempotency_key, ) def update_custom_area_schedule( - self, *, items: Sequence[CustomAreaScheduleEntry] + self, *, items: Sequence[CustomAreaScheduleEntry], idempotency_key: str | None = None ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).update_custom_area_schedule( - CustomAreaScheduleRequest(items=list(items)) + items=list(items), + idempotency_key=idempotency_key, ) - def cancel_parcel(self, *, parcel_id: str, actor: str) -> DeliveryEntityResult: + def cancel_parcel( + self, *, parcel_id: str, actor: str, idempotency_key: str | None = None + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).cancel_parcel( - CancelParcelRequest(parcel_id=parcel_id, actor=actor) + parcel_id=parcel_id, + actor=actor, + idempotency_key=idempotency_key, ) - def check_confirmation_code(self, *, parcel_id: str, confirm_code: str) -> DeliveryEntityResult: + def check_confirmation_code( + self, *, parcel_id: str, confirm_code: str, idempotency_key: str | None = None + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).check_confirmation_code( - SandboxConfirmationCodeRequest(parcel_id=parcel_id, confirm_code=confirm_code) + parcel_id=parcel_id, + confirm_code=confirm_code, + idempotency_key=idempotency_key, ) def set_order_properties( - self, *, order_id: str, properties: OrderDeliveryProperties + self, + *, + order_id: str, + properties: OrderDeliveryProperties, + idempotency_key: str | None = None, ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).set_order_properties( - SetOrderPropertiesRequest(order_id=order_id, properties=properties) + order_id=order_id, + properties=properties, + idempotency_key=idempotency_key, ) - def set_order_real_address(self, *, order_id: str, address: RealAddress) -> DeliveryEntityResult: + def set_order_real_address( + self, *, order_id: str, address: RealAddress, idempotency_key: str | None = None + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).set_order_real_address( - SetOrderRealAddressRequest(order_id=order_id, address=address) + order_id=order_id, + address=address, + idempotency_key=idempotency_key, ) def tracking( self, *, order_id: str, - avito_status: str, - avito_event_type: str, + avito_status: TrackingAvitoStatus | str, + avito_event_type: TrackingAvitoEventType | str, provider_event_code: str, date: str, location: str, comment: str | None = None, options: DeliveryTrackingOptions | None = None, + idempotency_key: str | None = None, ) -> DeliveryEntityResult: 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: + 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, + idempotency_key=idempotency_key, + ) + + def prohibit_order_acceptance( + self, *, order_id: str, idempotency_key: str | None = None + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).prohibit_order_acceptance( - ProhibitOrderAcceptanceRequest(order_id=order_id) + order_id=order_id, + idempotency_key=idempotency_key, ) def list_sorting_center(self) -> DeliverySortingCentersResult: return SandboxDeliveryClient(self.transport).list_sorting_center() - def add_sorting_center(self, *, items: Sequence[SortingCenterUpload]) -> DeliveryEntityResult: + def add_sorting_center( + self, *, items: Sequence[SortingCenterUpload], idempotency_key: str | None = None + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).add_sorting_center( - AddSortingCentersRequest(items=list(items)) + items=list(items), + idempotency_key=idempotency_key, ) - def add_areas(self, *, tariff_id: str, areas: Sequence[SandboxArea]) -> DeliveryEntityResult: + def add_areas( + self, + *, + tariff_id: str, + areas: Sequence[SandboxArea], + idempotency_key: str | None = None, + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).add_areas( tariff_id=tariff_id, - request=SandboxAreasRequest(areas=list(areas)), + areas=list(areas), + idempotency_key=idempotency_key, ) def add_tags_to_sorting_center( - self, *, tariff_id: str, items: Sequence[TaggedSortingCenter] + self, + *, + tariff_id: str, + items: Sequence[TaggedSortingCenter], + idempotency_key: str | None = None, ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).add_tags_to_sorting_center( tariff_id=tariff_id, - request=TaggedSortingCentersRequest(items=list(items)), + items=list(items), + idempotency_key=idempotency_key, ) def add_terminals( - self, *, tariff_id: str, items: Sequence[TerminalUpload] + self, + *, + tariff_id: str, + items: Sequence[TerminalUpload], + idempotency_key: str | None = None, ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).add_terminals( tariff_id=tariff_id, - request=AddTerminalsRequest(items=list(items)), + items=list(items), + idempotency_key=idempotency_key, ) - def update_terms(self, *, tariff_id: str, items: Sequence[DeliveryTermsZone]) -> DeliveryEntityResult: + def update_terms( + self, + *, + tariff_id: str, + items: Sequence[DeliveryTermsZone], + idempotency_key: str | None = None, + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).update_terms( tariff_id=tariff_id, - request=UpdateTermsRequest(items=list(items)), + items=list(items), + idempotency_key=idempotency_key, ) def add_tariff( @@ -307,21 +388,29 @@ def add_tariff( tariff_zones: Sequence[DeliveryTariffZone], terms_zones: Sequence[DeliveryTermsZone], tariff_type: str | None = None, + idempotency_key: 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, - ) + name=name, + delivery_provider_tariff_id=delivery_provider_tariff_id, + directions=list(directions), + tariff_zones=list(tariff_zones), + terms_zones=list(terms_zones), + tariff_type=tariff_type, + idempotency_key=idempotency_key, ) - def create_parcel(self, *, order_id: str, parcel_id: str) -> DeliveryEntityResult: + def create_parcel( + self, + *, + order_id: str, + parcel_id: str, + idempotency_key: str | None = None, + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).create_parcel( - DeliveryParcelRequest(order_id=order_id, parcel_id=parcel_id) + order_id=order_id, + parcel_id=parcel_id, + idempotency_key=idempotency_key, ) def cancel_sandbox_announcement( @@ -330,13 +419,13 @@ def cancel_sandbox_announcement( announcement_id: str, date: str, options: SandboxCancelAnnouncementOptions, + idempotency_key: str | None = None, ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).cancel_sandbox_announcement( - SandboxCancelAnnouncementRequest( - announcement_id=announcement_id, - date=date, - options=options, - ) + announcement_id=announcement_id, + date=date, + options=options, + idempotency_key=idempotency_key, ) def cancel_sandbox_parcel( @@ -344,9 +433,12 @@ def cancel_sandbox_parcel( *, parcel_id: str, options: CancelSandboxParcelOptions | None = None, + idempotency_key: str | None = None, ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).cancel_sandbox_parcel( - CancelSandboxParcelRequest(parcel_id=parcel_id, options=options) + parcel_id=parcel_id, + options=options, + idempotency_key=idempotency_key, ) def change_sandbox_parcel( @@ -356,14 +448,14 @@ def change_sandbox_parcel( parcel_id: str, application: ChangeParcelApplication | None = None, options: ChangeParcelOptions | None = None, + idempotency_key: str | None = None, ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).change_sandbox_parcel( - ChangeParcelRequest( - type=type, - parcel_id=parcel_id, - application=application, - options=options, - ) + type=type, + parcel_id=parcel_id, + application=application, + options=options, + idempotency_key=idempotency_key, ) def create_sandbox_announcement( @@ -377,38 +469,50 @@ def create_sandbox_announcement( date: str, packages: Sequence[SandboxAnnouncementPackage], options: SandboxCreateAnnouncementOptions, + idempotency_key: str | None = None, ) -> DeliveryEntityResult: 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, *, announcement_id: str) -> DeliveryEntityResult: + announcement_id=announcement_id, + barcode=barcode, + sender=sender, + receiver=receiver, + announcement_type=announcement_type, + date=date, + packages=list(packages), + options=options, + idempotency_key=idempotency_key, + ) + + def get_sandbox_announcement_event( + self, *, announcement_id: str, idempotency_key: str | None = None + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).get_sandbox_announcement_event( - SandboxGetAnnouncementEventRequest(announcement_id=announcement_id) + announcement_id=announcement_id, + idempotency_key=idempotency_key, ) - def get_sandbox_change_parcel_info(self, *, application_id: str) -> DeliveryEntityResult: + def get_sandbox_change_parcel_info( + self, *, application_id: str, idempotency_key: str | None = None + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).get_sandbox_change_parcel_info( - GetChangeParcelInfoRequest(application_id=application_id) + application_id=application_id, + idempotency_key=idempotency_key, ) - def get_sandbox_parcel_info(self, *, parcel_id: str) -> DeliveryEntityResult: + def get_sandbox_parcel_info( + self, *, parcel_id: str, idempotency_key: str | None = None + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).get_sandbox_parcel_info( - GetSandboxParcelInfoRequest(parcel_id=parcel_id) + parcel_id=parcel_id, + idempotency_key=idempotency_key, ) - def get_sandbox_registered_parcel_id(self, *, order_id: str) -> DeliveryEntityResult: + def get_sandbox_registered_parcel_id( + self, *, order_id: str, idempotency_key: str | None = None + ) -> DeliveryEntityResult: return SandboxDeliveryClient(self.transport).get_sandbox_registered_parcel_id( - GetRegisteredParcelIdRequest(order_id=order_id) + order_id=order_id, + idempotency_key=idempotency_key, ) @@ -436,13 +540,17 @@ class Stock(DomainObject): user_id: int | str | None = None def get(self, *, item_ids: Sequence[int]) -> StockInfoResult: - return StockManagementClient(self.transport).get_info( - StockInfoRequest(item_ids=list(item_ids)) - ) + return StockManagementClient(self.transport).get_info(item_ids=list(item_ids)) - def update(self, *, stocks: Sequence[StockUpdateEntry]) -> StockUpdateResult: + def update( + self, + *, + stocks: Sequence[StockUpdateEntry], + idempotency_key: str | None = None, + ) -> StockUpdateResult: return StockManagementClient(self.transport).update_stocks( - StockUpdateRequest(stocks=list(stocks)) + stocks=list(stocks), + idempotency_key=idempotency_key, ) diff --git a/avito/orders/enums.py b/avito/orders/enums.py new file mode 100644 index 0000000..a1510c8 --- /dev/null +++ b/avito/orders/enums.py @@ -0,0 +1,58 @@ +"""Enum-значения раздела orders.""" + +from __future__ import annotations + +from enum import Enum + + +class OrderStatus(str, Enum): + """Статус заказа или операции над заказом.""" + + UNKNOWN = "__unknown__" + NEW = "new" + MARKED = "marked" + CONFIRMED = "confirmed" + CODE_VALID = "code-valid" + RANGE_SET = "range-set" + TRACKING_SET = "tracking-set" + RETURN_ACCEPTED = "return-accepted" + + +class LabelTaskStatus(str, Enum): + """Статус задачи генерации этикеток.""" + + UNKNOWN = "__unknown__" + CREATED = "created" + + +class DeliveryStatus(str, Enum): + """Статус операции или задачи delivery API.""" + + UNKNOWN = "__unknown__" + ANNOUNCEMENT_CREATED = "announcement-created" + PARCEL_CREATED = "parcel-created" + ANNOUNCEMENT_CANCELLED = "announcement-cancelled" + CALLBACK_ACCEPTED = "callback-accepted" + PARCELS_UPDATED = "parcels-updated" + DONE = "done" + + +class TrackingAvitoStatus(str, Enum): + """Статус Avito для sandbox tracking-события.""" + + UNKNOWN = "__unknown__" + + +class TrackingAvitoEventType(str, Enum): + """Тип Avito-события для sandbox tracking.""" + + UNKNOWN = "__unknown__" + + +__all__ = ( + "DeliveryStatus", + "LabelTaskStatus", + "OrderStatus", + "TrackingAvitoEventType", + "TrackingAvitoStatus", +) diff --git a/avito/orders/mappers.py b/avito/orders/mappers.py index e2bfed7..c25d1c9 100644 --- a/avito/orders/mappers.py +++ b/avito/orders/mappers.py @@ -5,7 +5,9 @@ from collections.abc import Mapping from typing import cast +from avito.core.enums import map_enum_or_unknown from avito.core.exceptions import ResponseMappingError +from avito.orders.enums import DeliveryStatus, LabelTaskStatus, OrderStatus from avito.orders.models import ( CourierRange, CourierRangesResult, @@ -82,7 +84,11 @@ def map_orders(payload: object) -> OrdersResult: items=[ OrderSummary( order_id=_str(item, "id", "order_id", "orderId"), - status=_str(item, "status"), + status=map_enum_or_unknown( + _str(item, "status"), + OrderStatus, + enum_name="orders.order_status", + ), created_at=_str(item, "created", "created_at", "createdAt"), buyer_name=_str(_mapping(item, "buyerInfo"), "fullName"), total_price=_int(item, "totalPrice", "price"), @@ -102,7 +108,11 @@ def map_order_action(payload: object) -> OrderActionResult: return OrderActionResult( success=bool(source.get("success", data.get("success", True))), order_id=_str(source, "orderId", "order_id", "id"), - status=_str(source, "status"), + status=map_enum_or_unknown( + _str(source, "status"), + OrderStatus, + enum_name="orders.order_status", + ), message=_str(source, "message"), ) @@ -137,7 +147,11 @@ def map_label_task(payload: object) -> LabelTaskResult: task_int = _int(source, "taskId", "taskID") return LabelTaskResult( task_id=task_id or (str(task_int) if task_int is not None else None), - status=_str(source, "status"), + status=map_enum_or_unknown( + _str(source, "status"), + LabelTaskStatus, + enum_name="orders.label_task_status", + ), ) @@ -154,7 +168,11 @@ def map_delivery_entity(payload: object) -> DeliveryEntityResult: task_id=task_id or (str(task_int) if task_int is not None else None), order_id=_str(source, "orderId", "orderID"), parcel_id=_str(source, "parcelId", "parcelID"), - status=_str(source, "status"), + status=map_enum_or_unknown( + _str(source, "status"), + DeliveryStatus, + enum_name="orders.delivery_status", + ), message=_str(_mapping(data, "error"), "message") or _str(source, "message"), ) @@ -187,7 +205,11 @@ def map_delivery_task(payload: object) -> DeliveryTaskInfo: task_int = _int(source, "taskId", "taskID") return DeliveryTaskInfo( task_id=task_id or (str(task_int) if task_int is not None else None), - status=_str(source, "status"), + status=map_enum_or_unknown( + _str(source, "status"), + DeliveryStatus, + enum_name="orders.delivery_status", + ), error=_str(_mapping(data, "error"), "message") or _str(source, "error"), ) diff --git a/avito/orders/models.py b/avito/orders/models.py index c9cad74..54c17cf 100644 --- a/avito/orders/models.py +++ b/avito/orders/models.py @@ -7,6 +7,13 @@ from avito.core import BinaryResponse from avito.core.serialization import SerializableModel +from avito.orders.enums import ( + DeliveryStatus, + LabelTaskStatus, + OrderStatus, + TrackingAvitoEventType, + TrackingAvitoStatus, +) @dataclass(slots=True, frozen=True) @@ -301,8 +308,8 @@ class DeliveryTrackingRequest: """Запрос передачи tracking-события sandbox-заказа.""" order_id: str - avito_status: str - avito_event_type: str + avito_status: TrackingAvitoStatus | str + avito_event_type: TrackingAvitoEventType | str provider_event_code: str date: str location: str @@ -1020,7 +1027,7 @@ class OrderSummary(SerializableModel): """Краткая информация о заказе.""" order_id: str | None - status: str | None + status: OrderStatus | None created_at: str | None buyer_name: str | None total_price: int | None @@ -1040,7 +1047,7 @@ class OrderActionResult(SerializableModel): success: bool order_id: str | None = None - status: str | None = None + status: OrderStatus | None = None message: str | None = None @@ -1067,7 +1074,7 @@ class LabelTaskResult(SerializableModel): """Результат генерации этикеток.""" task_id: str | None - status: str | None = None + status: LabelTaskStatus | None = None @dataclass(slots=True, frozen=True) @@ -1103,7 +1110,7 @@ class DeliveryEntityResult(SerializableModel): task_id: str | None = None order_id: str | None = None parcel_id: str | None = None - status: str | None = None + status: DeliveryStatus | None = None message: str | None = None @@ -1128,7 +1135,7 @@ class DeliveryTaskInfo(SerializableModel): """Информация о задаче доставки.""" task_id: str | None - status: str | None + status: DeliveryStatus | None error: str | None diff --git a/avito/promotion/__init__.py b/avito/promotion/__init__.py index 059d0db..b8b1b71 100644 --- a/avito/promotion/__init__.py +++ b/avito/promotion/__init__.py @@ -8,6 +8,12 @@ TargetActionPricing, TrxPromotion, ) +from avito.promotion.enums import ( + CampaignType, + PromotionStatus, + TargetActionBudgetType, + TargetActionSelectedType, +) from avito.promotion.models import ( AutostrategyBudget, AutostrategyStat, @@ -74,6 +80,7 @@ "CampaignForecastRange", "CampaignInfo", "CampaignItem", + "CampaignType", "CampaignListFilter", "CampaignOrderBy", "CampaignUpdateTimeFilter", @@ -89,6 +96,7 @@ "PromotionOrderStatusItem", "PromotionOrderStatusResult", "PromotionOrdersResult", + "PromotionStatus", "PromotionService", "PromotionServiceDictionary", "PromotionServiceType", @@ -97,12 +105,14 @@ "TargetActionAutoBids", "TargetActionAutoPromotion", "TargetActionBid", + "TargetActionBudgetType", "TargetActionBudget", "TargetActionGetBidsResult", "TargetActionManualBids", "TargetActionManualPromotion", "TargetActionPromotion", "TargetActionPromotionsByItemIdsResult", + "TargetActionSelectedType", "TrxCommissionsResult", "TrxItem", "TrxPromotion", diff --git a/avito/promotion/client.py b/avito/promotion/client.py index 0c5f944..d280bea 100644 --- a/avito/promotion/client.py +++ b/avito/promotion/client.py @@ -3,9 +3,11 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime from avito.core import RequestContext, Transport from avito.core.mapping import request_public_model +from avito.promotion.enums import CampaignType, TargetActionBudgetType from avito.promotion.mappers import ( map_autostrategy_budget, map_autostrategy_stat, @@ -28,9 +30,12 @@ AutostrategyBudget, AutostrategyStat, BbipForecastsResult, + BbipItem, BbipSuggestsResult, CampaignActionResult, CampaignDetailsResult, + CampaignListFilter, + CampaignOrderBy, CampaignsResult, CancelTrxPromotionRequest, CpaAuctionBidsResult, @@ -39,6 +44,7 @@ CreateBbipForecastsRequest, CreateBbipOrderRequest, CreateBbipSuggestsRequest, + CreateItemBid, CreateItemBidsRequest, CreateTrxPromotionApplyRequest, DeletePromotionRequest, @@ -58,13 +64,14 @@ TargetActionGetBidsResult, TargetActionPromotionsByItemIdsResult, TrxCommissionsResult, + TrxItem, UpdateAutoBidRequest, UpdateAutostrategyCampaignRequest, UpdateManualBidRequest, ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class PromotionClient: """Выполняет HTTP-операции общего promotion API.""" @@ -81,7 +88,7 @@ def get_service_dictionary(self) -> PromotionServiceDictionary: mapper=map_promotion_service_dictionary, ) - def list_services(self, request: ListPromotionServicesRequest) -> PromotionServicesResult: + def list_services(self, *, item_ids: list[int]) -> PromotionServicesResult: """Получает список услуг продвижения по объявлениям.""" return request_public_model( @@ -90,10 +97,12 @@ def list_services(self, request: ListPromotionServicesRequest) -> PromotionServi "/promotion/v1/items/services/get", context=RequestContext("promotion.list_services", allow_retry=True), mapper=map_promotion_services, - json_body=request.to_payload(), + json_body=ListPromotionServicesRequest(item_ids=item_ids).to_payload(), ) - def list_orders(self, request: ListPromotionOrdersRequest) -> PromotionOrdersResult: + def list_orders( + self, *, item_ids: list[int] | None = None, order_ids: list[str] | None = None + ) -> PromotionOrdersResult: """Получает список заявок на продвижение.""" return request_public_model( @@ -102,12 +111,10 @@ def list_orders(self, request: ListPromotionOrdersRequest) -> PromotionOrdersRes "/promotion/v1/items/services/orders/get", context=RequestContext("promotion.list_orders", allow_retry=True), mapper=map_promotion_orders, - json_body=request.to_payload(), + json_body=ListPromotionOrdersRequest(item_ids=item_ids, order_ids=order_ids).to_payload(), ) - def get_order_status( - self, request: GetPromotionOrderStatusRequest - ) -> PromotionOrderStatusResult: + def get_order_status(self, *, order_ids: list[str]) -> PromotionOrderStatusResult: """Получает статусы заявок на продвижение.""" return request_public_model( @@ -116,17 +123,17 @@ def get_order_status( "/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(), + json_body=GetPromotionOrderStatusRequest(order_ids=order_ids).to_payload(), ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class BbipClient: """Выполняет HTTP-операции BBIP-продвижения.""" transport: Transport - def get_forecasts(self, request: CreateBbipForecastsRequest) -> BbipForecastsResult: + def get_forecasts(self, *, items: list[BbipItem]) -> BbipForecastsResult: """Получает прогнозы BBIP по объявлениям.""" return request_public_model( @@ -135,30 +142,36 @@ def get_forecasts(self, request: CreateBbipForecastsRequest) -> BbipForecastsRes "/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(), + json_body=CreateBbipForecastsRequest(items=items).to_payload(), ) def create_order( self, - request: CreateBbipOrderRequest, + *, + items: list[BbipItem], + idempotency_key: str | None = None, ) -> PromotionActionResult: """Подключает BBIP-услугу.""" - payload_to_send = request.to_payload() - payload = self.transport.request_json( + payload_to_send = CreateBbipOrderRequest(items=items).to_payload() + return self.transport.request_public_model( "PUT", "/promotion/v1/items/services/bbip/orders/create", - context=RequestContext("promotion.bbip.create_order", allow_retry=True), + context=RequestContext( + "promotion.bbip.create_order", + allow_retry=idempotency_key is not None, + ), + mapper=lambda payload: map_promotion_action( + payload, + action="create_order", + target={"item_ids": [item.item_id for item in items]}, + request_payload=payload_to_send, + ), json_body=payload_to_send, - ) - return map_promotion_action( - payload, - action="create_order", - target={"item_ids": [item.item_id for item in request.items]}, - request_payload=payload_to_send, + idempotency_key=idempotency_key, ) - def get_suggests(self, request: CreateBbipSuggestsRequest) -> BbipSuggestsResult: + def get_suggests(self, *, item_ids: list[int]) -> BbipSuggestsResult: """Получает варианты бюджета BBIP.""" return request_public_model( @@ -167,11 +180,11 @@ def get_suggests(self, request: CreateBbipSuggestsRequest) -> BbipSuggestsResult "/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(), + json_body=CreateBbipSuggestsRequest(item_ids=item_ids).to_payload(), ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class TrxPromoClient: """Выполняет HTTP-операции TrxPromo.""" @@ -179,42 +192,48 @@ class TrxPromoClient: def apply( self, - request: CreateTrxPromotionApplyRequest, + *, + items: list[TrxItem], + idempotency_key: str | None = None, ) -> PromotionActionResult: """Запускает TrxPromo.""" - payload_to_send = request.to_payload() - payload = self.transport.request_json( + payload_to_send = CreateTrxPromotionApplyRequest(items=items).to_payload() + return self.transport.request_public_model( "POST", "/trx-promo/1/apply", - context=RequestContext("promotion.trx.apply", allow_retry=True), + context=RequestContext("promotion.trx.apply", allow_retry=idempotency_key is not None), + mapper=lambda payload: map_promotion_action( + payload, + action="apply", + target={"item_ids": [item.item_id for item in items]}, + request_payload=payload_to_send, + ), json_body=payload_to_send, - ) - return map_promotion_action( - payload, - action="apply", - target={"item_ids": [item.item_id for item in request.items]}, - request_payload=payload_to_send, + idempotency_key=idempotency_key, ) def cancel( self, - request: CancelTrxPromotionRequest, + *, + item_ids: list[int], + idempotency_key: str | None = None, ) -> PromotionActionResult: """Останавливает TrxPromo.""" - payload_to_send = request.to_payload() - payload = self.transport.request_json( + payload_to_send = CancelTrxPromotionRequest(item_ids=item_ids).to_payload() + return self.transport.request_public_model( "POST", "/trx-promo/1/cancel", - context=RequestContext("promotion.trx.cancel", allow_retry=True), + context=RequestContext("promotion.trx.cancel", allow_retry=idempotency_key is not None), + mapper=lambda payload: map_promotion_action( + payload, + action="delete", + target={"item_ids": list(item_ids)}, + request_payload=payload_to_send, + ), json_body=payload_to_send, - ) - return map_promotion_action( - payload, - action="delete", - target={"item_ids": list(request.item_ids)}, - request_payload=payload_to_send, + idempotency_key=idempotency_key, ) def get_commissions(self, *, item_ids: list[int] | None = None) -> TrxCommissionsResult: @@ -231,7 +250,7 @@ def get_commissions(self, *, item_ids: list[int] | None = None) -> TrxCommission ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class CpaAuctionClient: """Выполняет HTTP-операции CPA-аукциона.""" @@ -256,26 +275,32 @@ def get_user_bids( def create_item_bids( self, - request: CreateItemBidsRequest, + *, + items: list[CreateItemBid], + idempotency_key: str | None = None, ) -> PromotionActionResult: """Сохраняет новые ставки.""" - payload_to_send = request.to_payload() - payload = self.transport.request_json( + payload_to_send = CreateItemBidsRequest(items=items).to_payload() + return self.transport.request_public_model( "POST", "/auction/1/bids", - context=RequestContext("promotion.cpa_auction.create_item_bids", allow_retry=True), + context=RequestContext( + "promotion.cpa_auction.create_item_bids", + allow_retry=idempotency_key is not None, + ), + mapper=lambda payload: map_promotion_action( + payload, + action="create_item_bids", + target={"item_ids": [item.item_id for item in items]}, + request_payload=payload_to_send, + ), json_body=payload_to_send, - ) - return map_promotion_action( - payload, - action="create_item_bids", - target={"item_ids": [item.item_id for item in request.items]}, - request_payload=payload_to_send, + idempotency_key=idempotency_key, ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class TargetActionPriceClient: """Выполняет HTTP-операции цены целевого действия.""" @@ -294,7 +319,7 @@ def get_bids(self, *, item_id: int) -> TargetActionGetBidsResult: def get_promotions_by_item_ids( self, - request: GetPromotionsByItemIdsRequest, + item_ids: list[int], ) -> TargetActionPromotionsByItemIdsResult: """Получает текущие цены и бюджеты по нескольким объявлениям.""" @@ -306,77 +331,118 @@ def get_promotions_by_item_ids( "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(), + json_body=GetPromotionsByItemIdsRequest(item_ids=item_ids).to_payload(), ) def delete_promotion( self, - request: DeletePromotionRequest, + *, + item_id: int, + idempotency_key: str | None = None, ) -> PromotionActionResult: """Останавливает продвижение с ценой целевого действия.""" - payload_to_send = request.to_payload() - payload = self.transport.request_json( + payload_to_send = DeletePromotionRequest(item_id=item_id).to_payload() + return self.transport.request_public_model( "POST", "/cpxpromo/1/remove", - context=RequestContext("promotion.target_action.delete_promotion", allow_retry=True), + context=RequestContext( + "promotion.target_action.delete_promotion", + allow_retry=idempotency_key is not None, + ), + mapper=lambda payload: map_promotion_action( + payload, + action="delete", + target={"item_id": item_id}, + request_payload=payload_to_send, + ), json_body=payload_to_send, - ) - return map_promotion_action( - payload, - action="delete", - target={"item_id": request.item_id}, - request_payload=payload_to_send, + idempotency_key=idempotency_key, ) def update_auto_bid( self, - request: UpdateAutoBidRequest, + *, + item_id: int, + action_type_id: int, + budget_penny: int, + budget_type: TargetActionBudgetType | str, + idempotency_key: str | None = None, ) -> PromotionActionResult: """Применяет автоматическую настройку.""" - payload_to_send = request.to_payload() - payload = self.transport.request_json( + payload_to_send = UpdateAutoBidRequest( + item_id=item_id, + action_type_id=action_type_id, + budget_penny=budget_penny, + budget_type=budget_type, + ).to_payload() + return self.transport.request_public_model( "POST", "/cpxpromo/1/setAuto", - context=RequestContext("promotion.target_action.update_auto_bid", allow_retry=True), + context=RequestContext( + "promotion.target_action.update_auto_bid", + allow_retry=idempotency_key is not None, + ), + mapper=lambda payload: map_promotion_action( + payload, + action="update_auto", + target={"item_id": item_id}, + request_payload=payload_to_send, + ), json_body=payload_to_send, - ) - return map_promotion_action( - payload, - action="update_auto", - target={"item_id": request.item_id}, - request_payload=payload_to_send, + idempotency_key=idempotency_key, ) def update_manual_bid( self, - request: UpdateManualBidRequest, + *, + item_id: int, + action_type_id: int, + bid_penny: int, + limit_penny: int | None = None, + idempotency_key: str | None = None, ) -> PromotionActionResult: """Применяет ручную настройку.""" - payload_to_send = request.to_payload() - payload = self.transport.request_json( + payload_to_send = UpdateManualBidRequest( + item_id=item_id, + action_type_id=action_type_id, + bid_penny=bid_penny, + limit_penny=limit_penny, + ).to_payload() + return self.transport.request_public_model( "POST", "/cpxpromo/1/setManual", - context=RequestContext("promotion.target_action.update_manual_bid", allow_retry=True), + context=RequestContext( + "promotion.target_action.update_manual_bid", + allow_retry=idempotency_key is not None, + ), + mapper=lambda payload: map_promotion_action( + payload, + action="update_manual", + target={"item_id": item_id}, + request_payload=payload_to_send, + ), json_body=payload_to_send, - ) - return map_promotion_action( - payload, - action="update_manual", - target={"item_id": request.item_id}, - request_payload=payload_to_send, + idempotency_key=idempotency_key, ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class AutostrategyClient: """Выполняет HTTP-операции автостратегии.""" transport: Transport - def create_budget(self, request: CreateAutostrategyBudgetRequest) -> AutostrategyBudget: + def create_budget( + self, + *, + campaign_type: CampaignType | str, + start_time: datetime | None = None, + finish_time: datetime | None = None, + items: list[int] | None = None, + ) -> AutostrategyBudget: """Рассчитывает бюджет кампании.""" return request_public_model( @@ -385,34 +451,95 @@ def create_budget(self, request: CreateAutostrategyBudgetRequest) -> Autostrateg "/autostrategy/v1/budget", context=RequestContext("promotion.autostrategy.create_budget", allow_retry=True), mapper=map_autostrategy_budget, - json_body=request.to_payload(), + json_body=CreateAutostrategyBudgetRequest( + campaign_type=campaign_type, + start_time=start_time, + finish_time=finish_time, + items=items, + ).to_payload(), ) - def create_campaign(self, request: CreateAutostrategyCampaignRequest) -> CampaignActionResult: + def create_campaign( + self, + *, + campaign_type: CampaignType | str, + title: str, + budget: int | None = None, + budget_bonus: int | None = None, + budget_real: int | None = None, + calc_id: int | None = None, + description: str | None = None, + finish_time: datetime | None = None, + items: list[int] | None = None, + start_time: datetime | None = None, + idempotency_key: str | None = None, + ) -> CampaignActionResult: """Создает новую кампанию.""" return request_public_model( self.transport, "POST", "/autostrategy/v1/campaign/create", - context=RequestContext("promotion.autostrategy.create_campaign", allow_retry=True), + context=RequestContext( + "promotion.autostrategy.create_campaign", + allow_retry=idempotency_key is not None, + ), mapper=map_campaign_action, - json_body=request.to_payload(), + json_body=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, + ).to_payload(), + idempotency_key=idempotency_key, ) - def edit_campaign(self, request: UpdateAutostrategyCampaignRequest) -> CampaignActionResult: + def edit_campaign( + self, + *, + campaign_id: int, + version: int, + budget: int | None = None, + calc_id: int | None = None, + description: str | None = None, + finish_time: datetime | None = None, + items: list[int] | None = None, + start_time: datetime | None = None, + title: str | None = None, + idempotency_key: str | None = None, + ) -> CampaignActionResult: """Редактирует кампанию.""" return request_public_model( self.transport, "POST", "/autostrategy/v1/campaign/edit", - context=RequestContext("promotion.autostrategy.edit_campaign", allow_retry=True), + context=RequestContext( + "promotion.autostrategy.edit_campaign", + allow_retry=idempotency_key is not None, + ), mapper=map_campaign_action, - json_body=request.to_payload(), + json_body=UpdateAutostrategyCampaignRequest( + campaign_id=campaign_id, + version=version, + budget=budget, + calc_id=calc_id, + description=description, + finish_time=finish_time, + items=items, + start_time=start_time, + title=title, + ).to_payload(), + idempotency_key=idempotency_key, ) - def get_campaign_info(self, request: GetAutostrategyCampaignInfoRequest) -> CampaignDetailsResult: + def get_campaign_info(self, *, campaign_id: int) -> CampaignDetailsResult: """Получает полную информацию о кампании.""" return request_public_model( @@ -421,22 +548,43 @@ def get_campaign_info(self, request: GetAutostrategyCampaignInfoRequest) -> Camp "/autostrategy/v1/campaign/info", context=RequestContext("promotion.autostrategy.get_campaign_info", allow_retry=True), mapper=map_campaign_info, - json_body=request.to_payload(), + json_body=GetAutostrategyCampaignInfoRequest(campaign_id=campaign_id).to_payload(), ) - def stop_campaign(self, request: StopAutostrategyCampaignRequest) -> CampaignActionResult: + def stop_campaign( + self, + *, + campaign_id: int, + version: int, + idempotency_key: str | None = None, + ) -> CampaignActionResult: """Останавливает кампанию.""" return request_public_model( self.transport, "POST", "/autostrategy/v1/campaign/stop", - context=RequestContext("promotion.autostrategy.stop_campaign", allow_retry=True), + context=RequestContext( + "promotion.autostrategy.stop_campaign", + allow_retry=idempotency_key is not None, + ), mapper=map_campaign_action, - json_body=request.to_payload(), + json_body=StopAutostrategyCampaignRequest( + campaign_id=campaign_id, + version=version, + ).to_payload(), + idempotency_key=idempotency_key, ) - def list_campaigns(self, request: ListAutostrategyCampaignsRequest) -> CampaignsResult: + def list_campaigns( + 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 request_public_model( @@ -445,10 +593,16 @@ def list_campaigns(self, request: ListAutostrategyCampaignsRequest) -> Campaigns "/autostrategy/v1/campaigns", context=RequestContext("promotion.autostrategy.list_campaigns", allow_retry=True), mapper=map_campaigns, - json_body=request.to_payload(), + json_body=ListAutostrategyCampaignsRequest( + limit=limit, + offset=offset, + status_id=status_id, + order_by=order_by, + filter=filter, + ).to_payload(), ) - def get_stat(self, request: GetAutostrategyStatRequest) -> AutostrategyStat: + def get_stat(self, *, campaign_id: int) -> AutostrategyStat: """Получает статистику кампании.""" return request_public_model( @@ -457,7 +611,7 @@ def get_stat(self, request: GetAutostrategyStatRequest) -> AutostrategyStat: "/autostrategy/v1/stat", context=RequestContext("promotion.autostrategy.get_stat", allow_retry=True), mapper=map_autostrategy_stat, - json_body=request.to_payload(), + json_body=GetAutostrategyStatRequest(campaign_id=campaign_id).to_payload(), ) diff --git a/avito/promotion/domain.py b/avito/promotion/domain.py index ec5868f..216208e 100644 --- a/avito/promotion/domain.py +++ b/avito/promotion/domain.py @@ -21,6 +21,7 @@ TargetActionPriceClient, TrxPromoClient, ) +from avito.promotion.enums import CampaignType, PromotionStatus, TargetActionBudgetType from avito.promotion.models import ( AutostrategyBudget, AutostrategyStat, @@ -37,35 +38,21 @@ CampaignUpdateTimeFilter, CancelTrxPromotionRequest, CpaAuctionBidsResult, - CreateAutostrategyBudgetRequest, - CreateAutostrategyCampaignRequest, - CreateBbipForecastsRequest, CreateBbipOrderRequest, - CreateBbipSuggestsRequest, CreateItemBid, - CreateItemBidsRequest, CreateTrxPromotionApplyRequest, DeletePromotionRequest, - GetAutostrategyCampaignInfoRequest, - GetAutostrategyStatRequest, - GetPromotionOrderStatusRequest, - GetPromotionsByItemIdsRequest, - ListAutostrategyCampaignsRequest, - ListPromotionOrdersRequest, - ListPromotionServicesRequest, PromotionActionResult, PromotionOrdersResult, PromotionOrderStatusResult, PromotionServiceDictionary, PromotionServicesResult, - StopAutostrategyCampaignRequest, TargetActionGetBidsResult, TargetActionPromotionsByItemIdsResult, TrxCommissionsResult, TrxItem, TrxItemInput, UpdateAutoBidRequest, - UpdateAutostrategyCampaignRequest, UpdateManualBidRequest, ) @@ -79,7 +66,7 @@ def _preview_result( return PromotionActionResult( action=action, target=dict(target), - status="preview", + status=PromotionStatus.PREVIEW, applied=False, request_payload=dict(request_payload), details={"validated": True}, @@ -106,7 +93,7 @@ def list_services(self, *, item_ids: list[int]) -> PromotionServicesResult: """Получает список услуг продвижения по объявлениям.""" return PromotionClient(self.transport).list_services( - ListPromotionServicesRequest(item_ids=item_ids) + item_ids=item_ids ) def list_orders( @@ -118,7 +105,8 @@ def list_orders( """Получает список заявок на продвижение.""" return PromotionClient(self.transport).list_orders( - ListPromotionOrdersRequest(item_ids=item_ids, order_ids=order_ids) + item_ids=item_ids, + order_ids=order_ids, ) def get_order_status(self, *, order_ids: list[str] | None = None) -> PromotionOrderStatusResult: @@ -130,7 +118,7 @@ def get_order_status(self, *, order_ids: list[str] | None = None) -> PromotionOr if not resolved_order_ids: raise ValidationError("Для операции требуется хотя бы один `order_id`.") return PromotionClient(self.transport).get_order_status( - GetPromotionOrderStatusRequest(order_ids=resolved_order_ids) + order_ids=resolved_order_ids ) @@ -153,13 +141,14 @@ def get_forecasts(self, *, items: list[BbipItemInput]) -> BbipForecastsResult: ) for item in items ] - return BbipClient(self.transport).get_forecasts(CreateBbipForecastsRequest(items=bbip_items)) + return BbipClient(self.transport).get_forecasts(items=bbip_items) def create_order( self, *, items: list[BbipItemInput], dry_run: bool = False, + idempotency_key: str | None = None, ) -> PromotionActionResult: """Подключает BBIP-продвижение.""" @@ -178,8 +167,7 @@ def create_order( ) for item in items ] - request = CreateBbipOrderRequest(items=bbip_items) - request_payload = request.to_payload() + request_payload = CreateBbipOrderRequest(items=bbip_items).to_payload() target: dict[str, object] = {"item_ids": [item["item_id"] for item in items]} if dry_run: return _preview_result( @@ -187,14 +175,17 @@ def create_order( target=target, request_payload=request_payload, ) - return BbipClient(self.transport).create_order(request) + return BbipClient(self.transport).create_order( + items=bbip_items, + idempotency_key=idempotency_key, + ) def get_suggests(self, *, item_ids: list[int] | None = None) -> BbipSuggestsResult: """Получает варианты бюджета BBIP.""" resolved_item_ids = item_ids or self._resource_item_ids() return BbipClient(self.transport).get_suggests( - CreateBbipSuggestsRequest(item_ids=resolved_item_ids) + item_ids=resolved_item_ids ) def _resource_item_ids(self) -> list[int]: @@ -215,6 +206,7 @@ def apply( *, items: list[TrxItemInput], dry_run: bool = False, + idempotency_key: str | None = None, ) -> PromotionActionResult: """Запускает TrxPromo.""" @@ -233,29 +225,34 @@ def apply( ) for item in items ] - request = CreateTrxPromotionApplyRequest(items=trx_items) - request_payload = request.to_payload() + request_payload = CreateTrxPromotionApplyRequest(items=trx_items).to_payload() target: dict[str, object] = {"item_ids": [item["item_id"] for item in items]} if dry_run: return _preview_result(action="apply", target=target, request_payload=request_payload) - return TrxPromoClient(self.transport).apply(request) + return TrxPromoClient(self.transport).apply( + items=trx_items, + idempotency_key=idempotency_key, + ) def delete( self, *, item_ids: list[int] | None = None, dry_run: bool = False, + idempotency_key: str | None = None, ) -> PromotionActionResult: """Останавливает TrxPromo.""" resolved_item_ids = item_ids or self._resource_item_ids() validate_non_empty("item_ids", resolved_item_ids) - request = CancelTrxPromotionRequest(item_ids=resolved_item_ids) - request_payload = request.to_payload() + request_payload = CancelTrxPromotionRequest(item_ids=resolved_item_ids).to_payload() target = {"item_ids": list(resolved_item_ids)} if dry_run: return _preview_result(action="delete", target=target, request_payload=request_payload) - return TrxPromoClient(self.transport).cancel(request) + return TrxPromoClient(self.transport).cancel( + item_ids=resolved_item_ids, + idempotency_key=idempotency_key, + ) def get_commissions(self, *, item_ids: list[int] | None = None) -> TrxCommissionsResult: """Получает доступные комиссии TrxPromo.""" @@ -289,11 +286,19 @@ def get_user_bids( batch_size=batch_size, ) - def create_item_bids(self, *, items: list[BidItemInput]) -> PromotionActionResult: + def create_item_bids( + self, + *, + items: list[BidItemInput], + idempotency_key: str | None = None, + ) -> PromotionActionResult: """Сохраняет новые ставки по объявлениям.""" 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)) + return CpaAuctionClient(self.transport).create_item_bids( + items=bids, + idempotency_key=idempotency_key, + ) @dataclass(slots=True, frozen=True) @@ -317,7 +322,7 @@ def get_promotions_by_item_ids( resolved_item_ids = item_ids or [self._require_item_id()] return TargetActionPriceClient(self.transport).get_promotions_by_item_ids( - GetPromotionsByItemIdsRequest(item_ids=resolved_item_ids) + item_ids=resolved_item_ids ) def delete( @@ -325,26 +330,30 @@ def delete( *, item_id: int | None = None, dry_run: bool = False, + idempotency_key: str | None = None, ) -> 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() + request_payload = DeletePromotionRequest(item_id=resolved_item_id).to_payload() target = {"item_id": resolved_item_id} if dry_run: return _preview_result(action="delete", target=target, request_payload=request_payload) - return TargetActionPriceClient(self.transport).delete_promotion(request) + return TargetActionPriceClient(self.transport).delete_promotion( + item_id=resolved_item_id, + idempotency_key=idempotency_key, + ) def update_auto( self, *, action_type_id: int, budget_penny: int, - budget_type: str, + budget_type: TargetActionBudgetType | str, item_id: int | None = None, dry_run: bool = False, + idempotency_key: str | None = None, ) -> PromotionActionResult: """Применяет автоматическую настройку.""" @@ -353,13 +362,12 @@ def update_auto( 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( + request_payload = 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() + ).to_payload() target = {"item_id": resolved_item_id} if dry_run: return _preview_result( @@ -367,7 +375,13 @@ def update_auto( target=target, request_payload=request_payload, ) - return TargetActionPriceClient(self.transport).update_auto_bid(request) + return TargetActionPriceClient(self.transport).update_auto_bid( + item_id=resolved_item_id, + action_type_id=action_type_id, + budget_penny=budget_penny, + budget_type=budget_type, + idempotency_key=idempotency_key, + ) def update_manual( self, @@ -377,6 +391,7 @@ def update_manual( limit_penny: int | None = None, item_id: int | None = None, dry_run: bool = False, + idempotency_key: str | None = None, ) -> PromotionActionResult: """Применяет ручную настройку.""" @@ -386,13 +401,12 @@ def update_manual( validate_positive_int("bid_penny", bid_penny) if limit_penny is not None: validate_positive_int("limit_penny", limit_penny) - request = UpdateManualBidRequest( + request_payload = 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() + ).to_payload() target = {"item_id": resolved_item_id} if dry_run: return _preview_result( @@ -400,7 +414,13 @@ def update_manual( target=target, request_payload=request_payload, ) - return TargetActionPriceClient(self.transport).update_manual_bid(request) + return TargetActionPriceClient(self.transport).update_manual_bid( + item_id=resolved_item_id, + action_type_id=action_type_id, + bid_penny=bid_penny, + limit_penny=limit_penny, + idempotency_key=idempotency_key, + ) def _require_item_id(self) -> int: if self.item_id is None: @@ -418,7 +438,7 @@ class AutostrategyCampaign(DomainObject): def create_budget( self, *, - campaign_type: str, + campaign_type: CampaignType | str, start_time: datetime | None = None, finish_time: datetime | None = None, items: list[int] | None = None, @@ -428,18 +448,16 @@ def create_budget( _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, - start_time=start_time, - finish_time=finish_time, - items=items, - ) + campaign_type=campaign_type, + start_time=start_time, + finish_time=finish_time, + items=items, ) def create( self, *, - campaign_type: str, + campaign_type: CampaignType | str, title: str, budget: int | None = None, budget_bonus: int | None = None, @@ -449,24 +467,24 @@ def create( finish_time: datetime | None = None, items: list[int] | None = None, start_time: datetime | None = None, + idempotency_key: str | 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, - 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, - ) + campaign_type=campaign_type, + title=title, + budget=budget, + budget_bonus=budget_bonus, + budget_real=budget_real, + calc_id=calc_id, + description=description, + finish_time=finish_time, + items=items, + start_time=start_time, + idempotency_key=idempotency_key, ) def update( @@ -481,42 +499,45 @@ def update( items: list[int] | None = None, start_time: datetime | None = None, title: str | None = None, + idempotency_key: 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(), - version=version, - budget=budget, - calc_id=calc_id, - description=description, - finish_time=finish_time, - items=items, - start_time=start_time, - title=title, - ) + campaign_id=campaign_id or self._require_campaign_id(), + version=version, + budget=budget, + calc_id=calc_id, + description=description, + finish_time=finish_time, + items=items, + start_time=start_time, + title=title, + idempotency_key=idempotency_key, ) def get(self, *, campaign_id: int | None = None) -> CampaignDetailsResult: """Получает полную информацию о кампании.""" return AutostrategyClient(self.transport).get_campaign_info( - GetAutostrategyCampaignInfoRequest( - campaign_id=campaign_id or self._require_campaign_id() - ) + campaign_id=campaign_id or self._require_campaign_id() ) - def delete(self, *, version: int, campaign_id: int | None = None) -> CampaignActionResult: + def delete( + self, + *, + version: int, + campaign_id: int | None = None, + idempotency_key: str | None = None, + ) -> CampaignActionResult: """Останавливает кампанию.""" return AutostrategyClient(self.transport).stop_campaign( - StopAutostrategyCampaignRequest( - campaign_id=campaign_id or self._require_campaign_id(), - version=version, - ) + campaign_id=campaign_id or self._require_campaign_id(), + version=version, + idempotency_key=idempotency_key, ) def list( @@ -547,20 +568,18 @@ def list( else None ) return AutostrategyClient(self.transport).list_campaigns( - ListAutostrategyCampaignsRequest( - limit=limit, - offset=offset, - status_id=status_id, - order_by=order_by_payload, - filter=filter_payload, - ) + limit=limit, + offset=offset, + status_id=status_id, + order_by=order_by_payload, + filter=filter_payload, ) def get_stat(self, *, campaign_id: int | None = None) -> AutostrategyStat: """Получает статистику кампании.""" return AutostrategyClient(self.transport).get_stat( - GetAutostrategyStatRequest(campaign_id=campaign_id or self._require_campaign_id()) + campaign_id=campaign_id or self._require_campaign_id() ) def _require_campaign_id(self) -> int: diff --git a/avito/promotion/enums.py b/avito/promotion/enums.py new file mode 100644 index 0000000..902b1a3 --- /dev/null +++ b/avito/promotion/enums.py @@ -0,0 +1,53 @@ +"""Enum-значения раздела promotion.""" + +from __future__ import annotations + +from enum import Enum + + +class PromotionStatus(str, Enum): + """Статус promotion-объекта или операции.""" + + UNKNOWN = "__unknown__" + AVAILABLE = "available" + CREATED = "created" + PROCESSED = "processed" + REMOVED = "removed" + AUTO = "auto" + MANUAL = "manual" + APPLIED = "applied" + PARTIAL = "partial" + FAILED = "failed" + PREVIEW = "preview" + + +class TargetActionBudgetType(str, Enum): + """Тип бюджета цены целевого действия.""" + + UNKNOWN = "__unknown__" + DAILY = "1d" + WEEKLY = "7d" + MONTHLY = "30d" + + +class TargetActionSelectedType(str, Enum): + """Выбранный тип продвижения цены целевого действия.""" + + UNKNOWN = "__unknown__" + AUTO = "auto" + MANUAL = "manual" + + +class CampaignType(str, Enum): + """Тип автокампании.""" + + UNKNOWN = "__unknown__" + AUTOSTRATEGY = "AS" + + +__all__ = ( + "CampaignType", + "PromotionStatus", + "TargetActionBudgetType", + "TargetActionSelectedType", +) diff --git a/avito/promotion/mappers.py b/avito/promotion/mappers.py index 41a2728..b2e7d09 100644 --- a/avito/promotion/mappers.py +++ b/avito/promotion/mappers.py @@ -6,7 +6,14 @@ from datetime import datetime from typing import cast +from avito.core.enums import map_enum_or_unknown from avito.core.exceptions import ResponseMappingError +from avito.promotion.enums import ( + CampaignType, + PromotionStatus, + TargetActionBudgetType, + TargetActionSelectedType, +) from avito.promotion.models import ( AutostrategyBudget, AutostrategyBudgetPoint, @@ -147,7 +154,11 @@ def map_promotion_services(payload: object) -> PromotionServicesResult: service_code=_str(item, "serviceCode", "code"), service_name=_str(item, "serviceName", "name", "title"), price=_int(item, "price", "pricePenny"), - status=_str(item, "status"), + status=map_enum_or_unknown( + _str(item, "status"), + PromotionStatus, + enum_name="promotion.status", + ), ) for item in _items_payload(data) ], @@ -164,7 +175,11 @@ def map_promotion_orders(payload: object) -> PromotionOrdersResult: order_id=_str(item, "orderId", "orderID", "id"), item_id=_int(item, "itemId", "itemID"), service_code=_str(item, "serviceCode", "code"), - status=_str(item, "status"), + status=map_enum_or_unknown( + _str(item, "status"), + PromotionStatus, + enum_name="promotion.status", + ), created_at=_datetime(item, "createdAt", "created_at"), ) for item in _items_payload(data) @@ -177,7 +192,11 @@ def map_promotion_order_status(payload: object) -> PromotionOrderStatusResult: data = _expect_mapping(payload) order_id = _str(data, "orderId", "orderID", "id") - status = _str(data, "status") + status = map_enum_or_unknown( + _str(data, "status"), + PromotionStatus, + enum_name="promotion.status", + ) if order_id is None or status is None: raise ResponseMappingError( "Статус заявки promotion должен содержать `orderId` и `status`.", @@ -195,7 +214,11 @@ def map_promotion_order_status(payload: object) -> PromotionOrderStatusResult: item_id=_int(item, "itemId", "itemID"), price=_int(item, "price"), slug=_str(item, "slug"), - status=_str(item, "status"), + status=map_enum_or_unknown( + _str(item, "status"), + PromotionStatus, + enum_name="promotion.status", + ), error_reason=_str(item, "errorReason"), ) for item in _list(data, "items") @@ -248,14 +271,18 @@ def map_promotion_action( PromotionActionItem( item_id=_int(item, "itemId", "itemID"), success=bool(item.get("success", True)), - status=_str(item, "status"), + status=map_enum_or_unknown( + _str(item, "status"), + PromotionStatus, + enum_name="promotion.status", + ), message=_str(_mapping(item, "error"), "message") or _str(item, "message"), upstream_reference=_str(item, "orderId", "requestId", "promotionId", "id"), ) 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] + statuses = [item.status for item in items if item.status is not None] 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] = {} @@ -283,16 +310,25 @@ def map_promotion_action( ) -def _resolve_action_status(*, payload: Payload, statuses: list[str], applied: bool) -> str: +def _resolve_action_status( + *, + payload: Payload, + statuses: list[PromotionStatus], + applied: bool, +) -> PromotionStatus: 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") + return PromotionStatus.APPLIED if applied else PromotionStatus.PARTIAL + payload_status = map_enum_or_unknown( + _str(payload, "status"), + PromotionStatus, + enum_name="promotion.status", + ) if payload_status is not None: return payload_status - return "applied" if applied else "failed" + return PromotionStatus.APPLIED if applied else PromotionStatus.FAILED def _extract_upstream_reference( @@ -447,7 +483,11 @@ def _map_budget_values(payload: Payload, key: str) -> list[TargetActionBudget]: def _map_target_action_auto(payload: Payload) -> TargetActionAutoBids: return TargetActionAutoBids( budget_penny=_int(payload, "budgetPenny"), - budget_type=_str(payload, "budgetType"), + budget_type=map_enum_or_unknown( + _str(payload, "budgetType"), + TargetActionBudgetType, + enum_name="promotion.target_action_budget_type", + ), min_budget_penny=_int(payload, "minBudgetPenny"), max_budget_penny=_int(payload, "maxBudgetPenny"), daily_budget=_map_budget_values(payload, "dailyBudget"), @@ -461,7 +501,11 @@ def map_target_action_get_bids_out(payload: object) -> TargetActionGetBidsResult data = _expect_mapping(payload) action_type_id = _int(data, "actionTypeID") - selected_type = _str(data, "selectedType") + selected_type = map_enum_or_unknown( + _str(data, "selectedType"), + TargetActionSelectedType, + enum_name="promotion.target_action_selected_type", + ) if action_type_id is None or selected_type is None: raise ResponseMappingError( "Ответ getBids должен содержать `actionTypeID` и `selectedType`.", @@ -512,7 +556,11 @@ def map_target_action_get_promotions_by_item_ids_out( auto=( TargetActionAutoPromotion( budget_penny=_int(cast(Payload, item["autoPromotion"]), "budgetPenny"), - budget_type=_str(cast(Payload, item["autoPromotion"]), "budgetType"), + budget_type=map_enum_or_unknown( + _str(cast(Payload, item["autoPromotion"]), "budgetType"), + TargetActionBudgetType, + enum_name="promotion.target_action_budget_type", + ), ) if isinstance(item.get("autoPromotion"), Mapping) else None @@ -584,7 +632,11 @@ def _map_campaign(payload: Payload) -> CampaignInfo | None: return None return CampaignInfo( campaign_id=_int(payload, "campaignId"), - campaign_type=_str(payload, "campaignType"), + campaign_type=map_enum_or_unknown( + _str(payload, "campaignType"), + CampaignType, + enum_name="promotion.campaign_type", + ), budget=_int(payload, "budget"), balance=_int(payload, "balance"), create_time=_datetime(payload, "createTime"), diff --git a/avito/promotion/models.py b/avito/promotion/models.py index 757fc6c..f9d7421 100644 --- a/avito/promotion/models.py +++ b/avito/promotion/models.py @@ -7,6 +7,12 @@ from typing import TypedDict from avito.core.serialization import SerializableModel +from avito.promotion.enums import ( + CampaignType, + PromotionStatus, + TargetActionBudgetType, + TargetActionSelectedType, +) @dataclass(slots=True, frozen=True) @@ -44,7 +50,7 @@ class PromotionService(SerializableModel): service_code: str | None service_name: str | None price: int | None - status: str | None + status: PromotionStatus | None @dataclass(slots=True, frozen=True) @@ -79,7 +85,7 @@ class PromotionOrderInfo(SerializableModel): order_id: str | None item_id: int | None service_code: str | None - status: str | None + status: PromotionStatus | None created_at: datetime | None @@ -118,7 +124,7 @@ class PromotionOrderStatusItem(SerializableModel): item_id: int | None price: int | None slug: str | None - status: str | None + status: PromotionStatus | None error_reason: str | None @@ -127,7 +133,7 @@ class PromotionOrderStatusResult(SerializableModel): """Статус заявки на продвижение.""" order_id: str | None - status: str | None + status: PromotionStatus | None total_price: int | None items: list[PromotionOrderStatusItem] errors: list[PromotionOrderError] @@ -241,7 +247,7 @@ class PromotionActionItem(SerializableModel): item_id: int | None success: bool - status: str | None = None + status: PromotionStatus | None = None message: str | None = None upstream_reference: str | None = None @@ -252,7 +258,7 @@ class PromotionActionResult(SerializableModel): action: str target: dict[str, object] | None - status: str + status: PromotionStatus applied: bool request_payload: dict[str, object] | None = None warnings: list[str] = field(default_factory=list) @@ -465,7 +471,7 @@ class TargetActionAutoBids(SerializableModel): """Детали автоматического продвижения цены целевого действия.""" budget_penny: int | None - budget_type: str | None + budget_type: TargetActionBudgetType | None min_budget_penny: int | None max_budget_penny: int | None daily_budget: list[TargetActionBudget] @@ -478,7 +484,7 @@ class TargetActionGetBidsResult(SerializableModel): """Ответ GET /cpxpromo/1/getBids/{itemId}.""" action_type_id: int - selected_type: str + selected_type: TargetActionSelectedType auto: TargetActionAutoBids | None = None manual: TargetActionManualBids | None = None @@ -498,7 +504,7 @@ class TargetActionAutoPromotion(SerializableModel): """Текущий auto-budget по объявлению.""" budget_penny: int | None - budget_type: str | None + budget_type: TargetActionBudgetType | None @dataclass(slots=True, frozen=True) @@ -547,7 +553,7 @@ class UpdateAutoBidRequest: item_id: int action_type_id: int budget_penny: int - budget_type: str + budget_type: TargetActionBudgetType | str def to_payload(self) -> dict[str, object]: """Сериализует запрос автоматической настройки.""" @@ -623,7 +629,7 @@ class AutostrategyBudget(SerializableModel): class CreateAutostrategyBudgetRequest: """Запрос расчета бюджета кампании.""" - campaign_type: str + campaign_type: CampaignType | str start_time: datetime | None = None finish_time: datetime | None = None items: list[int] | None = None @@ -653,7 +659,7 @@ class CampaignInfo(SerializableModel): """Информация об автокампании.""" campaign_id: int | None - campaign_type: str | None + campaign_type: CampaignType | None budget: int | None balance: int | None create_time: datetime | None @@ -721,7 +727,7 @@ class AutostrategyStat(SerializableModel): class CreateAutostrategyCampaignRequest: """Запрос создания автокампании.""" - campaign_type: str + campaign_type: CampaignType | str title: str budget: int | None = None budget_bonus: int | None = None diff --git a/avito/ratings/__init__.py b/avito/ratings/__init__.py index d389823..3131a3c 100644 --- a/avito/ratings/__init__.py +++ b/avito/ratings/__init__.py @@ -1,6 +1,7 @@ """Пакет ratings.""" from avito.ratings.domain import RatingProfile, Review, ReviewAnswer +from avito.ratings.enums import ReviewStage from avito.ratings.models import RatingProfileInfo, ReviewAnswerInfo, ReviewInfo, ReviewsResult __all__ = ( @@ -10,5 +11,6 @@ "ReviewAnswer", "ReviewAnswerInfo", "ReviewInfo", + "ReviewStage", "ReviewsResult", ) diff --git a/avito/ratings/client.py b/avito/ratings/client.py index e37601f..c332e4f 100644 --- a/avito/ratings/client.py +++ b/avito/ratings/client.py @@ -15,42 +15,55 @@ ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class RatingsClient: """Выполняет HTTP-операции рейтингов и отзывов.""" transport: Transport - def create_review_answer(self, request: CreateReviewAnswerRequest) -> ReviewAnswerInfo: - payload = self.transport.request_json( + def create_review_answer( + self, + *, + review_id: int, + text: str, + idempotency_key: str | None = None, + ) -> ReviewAnswerInfo: + return self.transport.request_public_model( "POST", "/ratings/v1/answers", - context=RequestContext("ratings.answers.create", allow_retry=True), - json_body=request.to_payload(), + context=RequestContext("ratings.answers.create", allow_retry=idempotency_key is not None), + mapper=map_review_answer, + json_body=CreateReviewAnswerRequest(review_id=review_id, text=text).to_payload(), + idempotency_key=idempotency_key, ) - return map_review_answer(payload) - def delete_review_answer(self, *, answer_id: int | str) -> ReviewAnswerInfo: - payload = self.transport.request_json( + def delete_review_answer( + self, + *, + answer_id: int | str, + idempotency_key: str | None = None, + ) -> ReviewAnswerInfo: + return self.transport.request_public_model( "DELETE", f"/ratings/v1/answers/{answer_id}", - context=RequestContext("ratings.answers.delete", allow_retry=True), + context=RequestContext("ratings.answers.delete", allow_retry=idempotency_key is not None), + mapper=map_review_answer, + idempotency_key=idempotency_key, ) - return map_review_answer(payload) def get_ratings_info(self) -> RatingProfileInfo: - payload = self.transport.request_json( + return self.transport.request_public_model( "GET", "/ratings/v1/info", context=RequestContext("ratings.info.get"), + mapper=map_rating_profile, ) - return map_rating_profile(payload) def list_reviews(self, *, query: ReviewsQuery | None = None) -> ReviewsResult: - payload = self.transport.request_json( + return self.transport.request_public_model( "GET", "/ratings/v1/reviews", context=RequestContext("ratings.reviews.list"), + mapper=map_reviews, 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 71c784c..7ea7dab 100644 --- a/avito/ratings/domain.py +++ b/avito/ratings/domain.py @@ -8,7 +8,6 @@ from avito.core.domain import DomainObject from avito.ratings.client import RatingsClient from avito.ratings.models import ( - CreateReviewAnswerRequest, RatingProfileInfo, ReviewAnswerInfo, ReviewsQuery, @@ -33,14 +32,28 @@ class ReviewAnswer(DomainObject): answer_id: int | str | None = None user_id: int | str | None = None - def create(self, *, review_id: int, text: str) -> ReviewAnswerInfo: + def create( + self, + *, + review_id: int, + text: str, + idempotency_key: str | None = None, + ) -> ReviewAnswerInfo: return RatingsClient(self.transport).create_review_answer( - CreateReviewAnswerRequest(review_id=review_id, text=text) + review_id=review_id, + text=text, + idempotency_key=idempotency_key, ) - def delete(self, *, answer_id: int | str | None = None) -> ReviewAnswerInfo: + def delete( + self, + *, + answer_id: int | str | None = None, + idempotency_key: str | None = None, + ) -> ReviewAnswerInfo: return RatingsClient(self.transport).delete_review_answer( - answer_id=answer_id or self._require_answer_id() + answer_id=answer_id or self._require_answer_id(), + idempotency_key=idempotency_key, ) def _require_answer_id(self) -> str: diff --git a/avito/ratings/enums.py b/avito/ratings/enums.py new file mode 100644 index 0000000..18ed6d6 --- /dev/null +++ b/avito/ratings/enums.py @@ -0,0 +1,15 @@ +"""Enum-значения раздела ratings.""" + +from __future__ import annotations + +from enum import Enum + + +class ReviewStage(str, Enum): + """Этап обработки отзыва.""" + + UNKNOWN = "__unknown__" + DONE = "done" + + +__all__ = ("ReviewStage",) diff --git a/avito/ratings/mappers.py b/avito/ratings/mappers.py index e784ef2..7ed69b8 100644 --- a/avito/ratings/mappers.py +++ b/avito/ratings/mappers.py @@ -5,7 +5,9 @@ from collections.abc import Mapping from typing import cast +from avito.core.enums import map_enum_or_unknown from avito.core.exceptions import ResponseMappingError +from avito.ratings.enums import ReviewStage from avito.ratings.models import RatingProfileInfo, ReviewAnswerInfo, ReviewInfo, ReviewsResult Payload = Mapping[str, object] @@ -104,7 +106,11 @@ def map_reviews(payload: object) -> ReviewsResult: ReviewInfo( review_id=_str(item, "id"), score=_int(item, "score"), - stage=_str(item, "stage"), + stage=map_enum_or_unknown( + _str(item, "stage"), + ReviewStage, + enum_name="ratings.review_stage", + ), text=_str(item, "text"), created_at=_int(item, "createdAt"), can_answer=_bool(item, "canAnswer"), diff --git a/avito/ratings/models.py b/avito/ratings/models.py index b9a15cd..2788604 100644 --- a/avito/ratings/models.py +++ b/avito/ratings/models.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from avito.core.serialization import SerializableModel +from avito.ratings.enums import ReviewStage @dataclass(slots=True, frozen=True) @@ -41,7 +42,7 @@ class ReviewInfo(SerializableModel): review_id: str | None score: int | None - stage: str | None + stage: ReviewStage | None text: str | None created_at: int | None can_answer: bool | None diff --git a/avito/realty/__init__.py b/avito/realty/__init__.py index 65d9ad7..de62879 100644 --- a/avito/realty/__init__.py +++ b/avito/realty/__init__.py @@ -6,6 +6,7 @@ RealtyListing, RealtyPricing, ) +from avito.realty.enums import RealtyStatus from avito.realty.models import ( RealtyActionResult, RealtyAnalyticsInfo, @@ -38,4 +39,5 @@ "RealtyPricePeriod", "RealtyPricing", "RealtyPricesUpdateRequest", + "RealtyStatus", ) diff --git a/avito/realty/client.py b/avito/realty/client.py index 5cfb2d1..4d81779 100644 --- a/avito/realty/client.py +++ b/avito/realty/client.py @@ -13,73 +13,102 @@ RealtyBookingsQuery, RealtyBookingsResult, RealtyBookingsUpdateRequest, + RealtyInterval, RealtyIntervalsRequest, RealtyMarketPriceInfo, + RealtyPricePeriod, RealtyPricesUpdateRequest, ) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class ShortTermRentClient: """Выполняет HTTP-операции краткосрочной аренды.""" transport: Transport def update_bookings_info( - self, *, user_id: int | str, item_id: int | str, request: RealtyBookingsUpdateRequest + self, + *, + user_id: int | str, + item_id: int | str, + blocked_dates: list[str], + idempotency_key: str | None = None, ) -> RealtyActionResult: - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", f"/core/v1/accounts/{user_id}/items/{item_id}/bookings", - context=RequestContext("realty.bookings.update", allow_retry=True), - json_body=request.to_payload(), + context=RequestContext("realty.bookings.update", allow_retry=idempotency_key is not None), + mapper=map_action, + json_body=RealtyBookingsUpdateRequest(blocked_dates=blocked_dates).to_payload(), + idempotency_key=idempotency_key, ) - return map_action(payload) def list_realty_bookings( self, *, user_id: int | str, item_id: int | str, query: RealtyBookingsQuery ) -> RealtyBookingsResult: - payload = self.transport.request_json( + return self.transport.request_public_model( "GET", f"/realty/v1/accounts/{user_id}/items/{item_id}/bookings", context=RequestContext("realty.bookings.list"), + mapper=map_bookings, params=query.to_params(), ) - return map_bookings(payload) def update_realty_prices( - self, *, user_id: int | str, item_id: int | str, request: RealtyPricesUpdateRequest + self, + *, + user_id: int | str, + item_id: int | str, + periods: list[RealtyPricePeriod], + idempotency_key: str | None = None, ) -> RealtyActionResult: - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", f"/realty/v1/accounts/{user_id}/items/{item_id}/prices", - context=RequestContext("realty.prices.update", allow_retry=True), - json_body=request.to_payload(), + context=RequestContext("realty.prices.update", allow_retry=idempotency_key is not None), + mapper=map_action, + json_body=RealtyPricesUpdateRequest(periods=periods).to_payload(), + idempotency_key=idempotency_key, ) - return map_action(payload) - def get_intervals(self, request: RealtyIntervalsRequest) -> RealtyActionResult: - payload = self.transport.request_json( + def get_intervals( + self, + *, + item_id: int, + intervals: list[RealtyInterval], + idempotency_key: str | None = None, + ) -> RealtyActionResult: + return self.transport.request_public_model( "POST", "/realty/v1/items/intervals", - context=RequestContext("realty.intervals.fill", allow_retry=True), - json_body=request.to_payload(), + context=RequestContext("realty.intervals.fill", allow_retry=idempotency_key is not None), + mapper=map_action, + json_body=RealtyIntervalsRequest(item_id=item_id, intervals=intervals).to_payload(), + idempotency_key=idempotency_key, ) - return map_action(payload) def update_base_params( - self, *, item_id: int | str, request: RealtyBaseParamsUpdateRequest + self, + *, + item_id: int | str, + min_stay_days: int, + idempotency_key: str | None = None, ) -> RealtyActionResult: - payload = self.transport.request_json( + return self.transport.request_public_model( "POST", f"/realty/v1/items/{item_id}/base", - context=RequestContext("realty.base_params.update", allow_retry=True), - json_body=request.to_payload(), + context=RequestContext( + "realty.base_params.update", + allow_retry=idempotency_key is not None, + ), + mapper=map_action, + json_body=RealtyBaseParamsUpdateRequest(min_stay_days=min_stay_days).to_payload(), + idempotency_key=idempotency_key, ) - return map_action(payload) -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class RealtyAnalyticsClient: """Выполняет HTTP-операции аналитики недвижимости.""" @@ -88,17 +117,23 @@ class RealtyAnalyticsClient: def get_market_price_correspondence( self, *, item_id: int | str, price: int | str ) -> RealtyMarketPriceInfo: - payload = self.transport.request_json( + return self.transport.request_public_model( "GET", f"/realty/v1/marketPriceCorrespondence/{item_id}/{price}", context=RequestContext("realty.analytics.market_price"), + mapper=map_market_price, ) - return map_market_price(payload) - def get_report_for_classified(self, *, item_id: int | str) -> RealtyAnalyticsInfo: - payload = self.transport.request_json( + def get_report_for_classified( + self, + *, + item_id: int | str, + idempotency_key: str | None = None, + ) -> RealtyAnalyticsInfo: + return self.transport.request_public_model( "POST", f"/realty/v1/report/create/{item_id}", - context=RequestContext("realty.analytics.report", allow_retry=True), + context=RequestContext("realty.analytics.report", allow_retry=idempotency_key is not None), + mapper=map_analytics_report, + idempotency_key=idempotency_key, ) - return map_analytics_report(payload) diff --git a/avito/realty/domain.py b/avito/realty/domain.py index 3604936..873a0e6 100644 --- a/avito/realty/domain.py +++ b/avito/realty/domain.py @@ -10,14 +10,11 @@ from avito.realty.models import ( RealtyActionResult, RealtyAnalyticsInfo, - RealtyBaseParamsUpdateRequest, RealtyBookingsQuery, RealtyBookingsResult, - RealtyBookingsUpdateRequest, RealtyInterval, - RealtyIntervalsRequest, RealtyMarketPriceInfo, - RealtyPricesUpdateRequest, + RealtyPricePeriod, ) @@ -35,18 +32,16 @@ def get_intervals( 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, - ) + 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 + self, *, min_stay_days: int, item_id: int | str | None = None ) -> RealtyActionResult: return ShortTermRentClient(self.transport).update_base_params( item_id=item_id or self._require_item_id(), - request=request, + min_stay_days=min_stay_days, ) def _require_item_id(self) -> str: @@ -65,14 +60,14 @@ class RealtyBooking(DomainObject): def update_bookings_info( self, *, - request: RealtyBookingsUpdateRequest, + blocked_dates: list[str], 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=request, + blocked_dates=blocked_dates, ) def list_realty_bookings( @@ -115,14 +110,14 @@ class RealtyPricing(DomainObject): def update_realty_prices( self, *, - request: RealtyPricesUpdateRequest, + periods: list[RealtyPricePeriod], 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=request, + periods=periods, ) def _require_item_id(self) -> str: diff --git a/avito/realty/enums.py b/avito/realty/enums.py new file mode 100644 index 0000000..3202490 --- /dev/null +++ b/avito/realty/enums.py @@ -0,0 +1,16 @@ +"""Enum-значения раздела realty.""" + +from __future__ import annotations + +from enum import Enum + + +class RealtyStatus(str, Enum): + """Статус сущности realty.""" + + UNKNOWN = "__unknown__" + ACTIVE = "active" + SUCCESS = "success" + + +__all__ = ("RealtyStatus",) diff --git a/avito/realty/mappers.py b/avito/realty/mappers.py index cca2ab1..84bcf62 100644 --- a/avito/realty/mappers.py +++ b/avito/realty/mappers.py @@ -5,7 +5,9 @@ from collections.abc import Mapping from typing import cast +from avito.core.enums import map_enum_or_unknown from avito.core.exceptions import ResponseMappingError +from avito.realty.enums import RealtyStatus from avito.realty.models import ( RealtyActionResult, RealtyAnalyticsInfo, @@ -67,7 +69,11 @@ def map_action(payload: object) -> RealtyActionResult: data = _expect_mapping(payload) return RealtyActionResult( success=_str(data, "result") == "success" or bool(data.get("success", False)), - status=_str(data, "result", "status"), + status=map_enum_or_unknown( + _str(data, "result", "status"), + RealtyStatus, + enum_name="realty.status", + ), ) @@ -102,7 +108,11 @@ def map_bookings(payload: object) -> RealtyBookingsResult: if isinstance(item.get("safe_deposit"), Mapping) else None ), - status=_str(item, "status"), + status=map_enum_or_unknown( + _str(item, "status"), + RealtyStatus, + enum_name="realty.status", + ), ) for item in _list(data, "bookings", "items") ], diff --git a/avito/realty/models.py b/avito/realty/models.py index 0dff6f1..722d379 100644 --- a/avito/realty/models.py +++ b/avito/realty/models.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from avito.core.serialization import SerializableModel +from avito.realty.enums import RealtyStatus @dataclass(slots=True, frozen=True) @@ -12,7 +13,7 @@ class RealtyActionResult(SerializableModel): """Результат mutation-операции по недвижимости.""" success: bool - status: str | None = None + status: RealtyStatus | None = None @dataclass(slots=True, frozen=True) @@ -57,7 +58,7 @@ class RealtyBookingInfo(SerializableModel): guest_count: int | None nights: int | None safe_deposit: RealtyBookingSafeDeposit | None - status: str | None + status: RealtyStatus | None @dataclass(slots=True, frozen=True) @@ -166,4 +167,3 @@ class RealtyAnalyticsInfo(SerializableModel): report_link: str | None = None error_message: str | None = None - diff --git a/avito/tariffs/__init__.py b/avito/tariffs/__init__.py index cee4ad9..a207a09 100644 --- a/avito/tariffs/__init__.py +++ b/avito/tariffs/__init__.py @@ -1,6 +1,7 @@ """Пакет tariffs.""" from avito.tariffs.domain import Tariff +from avito.tariffs.enums import TariffLevel from avito.tariffs.models import TariffContractInfo, TariffInfo -__all__ = ("Tariff", "TariffContractInfo", "TariffInfo") +__all__ = ("Tariff", "TariffContractInfo", "TariffInfo", "TariffLevel") diff --git a/avito/tariffs/client.py b/avito/tariffs/client.py index f06af37..c85e8ae 100644 --- a/avito/tariffs/client.py +++ b/avito/tariffs/client.py @@ -10,7 +10,7 @@ from avito.tariffs.models import TariffInfo -@dataclass(slots=True) +@dataclass(slots=True, frozen=True) class TariffsClient: """Выполняет HTTP-операции тарифов.""" diff --git a/avito/tariffs/enums.py b/avito/tariffs/enums.py new file mode 100644 index 0000000..071c223 --- /dev/null +++ b/avito/tariffs/enums.py @@ -0,0 +1,16 @@ +"""Enum-значения раздела tariffs.""" + +from __future__ import annotations + +from enum import Enum + + +class TariffLevel(str, Enum): + """Уровень тарифного контракта.""" + + UNKNOWN = "__unknown__" + MAX = "Тариф Максимальный" + BASE = "Тариф Базовый" + + +__all__ = ("TariffLevel",) diff --git a/avito/tariffs/mappers.py b/avito/tariffs/mappers.py index f46362e..c96f478 100644 --- a/avito/tariffs/mappers.py +++ b/avito/tariffs/mappers.py @@ -5,7 +5,9 @@ from collections.abc import Mapping from typing import cast +from avito.core.enums import map_enum_or_unknown from avito.core.exceptions import ResponseMappingError +from avito.tariffs.enums import TariffLevel from avito.tariffs.models import TariffContractInfo, TariffInfo Payload = Mapping[str, object] @@ -68,7 +70,11 @@ def _map_contract(payload: Payload) -> TariffContractInfo | None: packages = payload.get("packages") packages_count = len(packages) if isinstance(packages, list) else None return TariffContractInfo( - level=_str(payload, "level"), + level=map_enum_or_unknown( + _str(payload, "level"), + TariffLevel, + enum_name="tariffs.level", + ), is_active=_bool(payload, "isActive"), start_time=_int(payload, "startTime"), close_time=_int(payload, "closeTime"), diff --git a/avito/tariffs/models.py b/avito/tariffs/models.py index 85822df..c3cb7d2 100644 --- a/avito/tariffs/models.py +++ b/avito/tariffs/models.py @@ -5,13 +5,14 @@ from dataclasses import dataclass from avito.core.serialization import SerializableModel +from avito.tariffs.enums import TariffLevel @dataclass(slots=True, frozen=True) class TariffContractInfo(SerializableModel): """Информация о текущем или запланированном тарифном контракте.""" - level: str | None + level: TariffLevel | None is_active: bool | None start_time: int | None close_time: int | None diff --git a/avito/testing/__init__.py b/avito/testing/__init__.py new file mode 100644 index 0000000..cdcdc14 --- /dev/null +++ b/avito/testing/__init__.py @@ -0,0 +1,5 @@ +"""Публичные тестовые утилиты SDK.""" + +from avito.testing.fake_transport import FakeResponse, FakeTransport + +__all__ = ("FakeTransport", "FakeResponse") diff --git a/avito/testing/fake_transport.py b/avito/testing/fake_transport.py new file mode 100644 index 0000000..b2e84ff --- /dev/null +++ b/avito/testing/fake_transport.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import json +from collections import deque +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass +from typing import cast + +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 +FakeResponse = httpx.Response + + +@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: + # `json.loads()` возвращает `Any` только на границе JSON-декодирования. + return cast(JsonValue, 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 + + +__all__ = ( + "FakeResponse", + "FakeTransport", + "JsonValue", + "RecordedRequest", + "json_response", + "route_sequence", +) diff --git a/poetry.lock b/poetry.lock index ee730e4..8d3b410 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,17 +1,5 @@ # This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. -[[package]] -name = "annotated-types" -version = "0.7.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - [[package]] name = "anyio" version = "4.13.0" @@ -30,18 +18,6 @@ idna = ">=2.8" [package.extras] trio = ["trio (>=0.32.0)"] -[[package]] -name = "backoff" -version = "2.2.1" -description = "Function decoration for backoff and retry" -optional = false -python-versions = ">=3.7,<4.0" -groups = ["main"] -files = [ - {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"}, - {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, -] - [[package]] name = "certifi" version = "2026.2.25" @@ -60,7 +36,7 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main", "dev"] +groups = ["dev"] markers = "sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, @@ -373,25 +349,6 @@ files = [ {file = "librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d"}, ] -[[package]] -name = "loguru" -version = "0.7.3" -description = "Python logging made (stupidly) simple" -optional = false -python-versions = "<4.0,>=3.5" -groups = ["main"] -files = [ - {file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"}, - {file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"}, -] - -[package.dependencies] -colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} -win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} - -[package.extras] -dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==0.910) ; python_version < \"3.6\"", "mypy (==0.971) ; python_version == \"3.6\"", "mypy (==1.13.0) ; python_version >= \"3.8\"", "mypy (==1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] - [[package]] name = "mypy" version = "1.20.1" @@ -518,185 +475,6 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] -[[package]] -name = "pydantic" -version = "2.13.2" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pydantic-2.13.2-py3-none-any.whl", hash = "sha256:a525087f4c03d7e7456a3de89b64cd693d2229933bb1068b9af6befd5563694e"}, - {file = "pydantic-2.13.2.tar.gz", hash = "sha256:b418196607e61081c3226dcd4f0672f2a194828abb9109e9cfb84026564df2d1"}, -] - -[package.dependencies] -annotated-types = ">=0.6.0" -pydantic-core = "2.46.2" -typing-extensions = ">=4.14.1" -typing-inspection = ">=0.4.2" - -[package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] - -[[package]] -name = "pydantic-core" -version = "2.46.2" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pydantic_core-2.46.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:160ef93541f4f84e3e5068e6c1f64d8fd6f57586e5853d609b467d3333f8146a"}, - {file = "pydantic_core-2.46.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a9124b63f4f40a12a0666df57450b4c24b98407ff74349221b869ec085a5d8e"}, - {file = "pydantic_core-2.46.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de12004a7da7f1eb67ece37439a5a23a915636085dd042176fda362e006e6940"}, - {file = "pydantic_core-2.46.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a070c7769fec277409ad0b3d55b2f0a3703a6f00cf5031fe93090f155bf56382"}, - {file = "pydantic_core-2.46.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41d701bb34f81f0b11c724cc544b9a10b26a28f4d0d1197f2037c91225708706"}, - {file = "pydantic_core-2.46.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19631e7350b7a574fb6b6db222f4b17e8bd31803074b3307d07df62379d2b2e4"}, - {file = "pydantic_core-2.46.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48b1059e4f2a6ec3e41983148eb1eec5ef9fa3a80bbc4ac0893ac76b115fe039"}, - {file = "pydantic_core-2.46.2-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:df73724fce8ad53c670358c905b37930bd7b9d92e57db640a65c53b2706eee00"}, - {file = "pydantic_core-2.46.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a0891a9be0def16fb320af21a198ece052eed72bf44d73d8ff43f702bd26fd6b"}, - {file = "pydantic_core-2.46.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2ca790779aa1cba1329b8dc42ccebada441d9ac1d932de980183d544682c646d"}, - {file = "pydantic_core-2.46.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:6b865eb702c3af71cf7331919a787563ce2413f7a54ef49ec6709a01b4f22ce6"}, - {file = "pydantic_core-2.46.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:631bec5f951a30a4b332b4a57d0cdd5a2c8187eb71301f966425f2e54a697855"}, - {file = "pydantic_core-2.46.2-cp310-cp310-win32.whl", hash = "sha256:8cbd9d67357f3a925f2af1d44db3e8ef1ce1a293ea0add98081b072d4a12e3b4"}, - {file = "pydantic_core-2.46.2-cp310-cp310-win_amd64.whl", hash = "sha256:dd51dd16182b4bfdcefd27b39b856aa4a57b77f15b231a2d10c45391b0a02028"}, - {file = "pydantic_core-2.46.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d8060f42db3cd204871db0afd51fef54a13fa544c4dd48cdcae2e174ef40c8ba"}, - {file = "pydantic_core-2.46.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:73a9d2809bd8d4a7cda4d336dc996a565eb4feaaa39932f9d85a65fa18382f28"}, - {file = "pydantic_core-2.46.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b0a2dee92dfaabcfb93629188c3e9cf74fdfc0f22e7c369cb444a98814a1e50"}, - {file = "pydantic_core-2.46.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3098446ba8cf774f61cb8d4008c1dba14a30426a15169cd95ac3392a461193b1"}, - {file = "pydantic_core-2.46.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57c584af6c375ea3f826d8131a94cb212b3d9926eaff67117e3711bbff3a83a5"}, - {file = "pydantic_core-2.46.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:547381cca999be88b4715a0ed7afa11f07fc7e53cb1883687b190d25a92c56cf"}, - {file = "pydantic_core-2.46.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:caeed15dcb1233a5a94bc6ff37ef5393cf5b33a45e4bdfb2d6042f3d24e1cb27"}, - {file = "pydantic_core-2.46.2-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:c05f53362568c75476b5c96659377a5dfd982cfbe5a5c07de5106d08a04efc4f"}, - {file = "pydantic_core-2.46.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2643ac7eae296200dbd48762a1c852cf2cad5f5e3eba34e652053cebf03becf8"}, - {file = "pydantic_core-2.46.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dc4620a47c6fe6a39f89392c00833a82fc050ce90169798f78a25a8d4df03b6e"}, - {file = "pydantic_core-2.46.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:78cb0d2453b50bf2035f85fd0d9cfabdb98c47f9c53ddb7c23873cd83da9560b"}, - {file = "pydantic_core-2.46.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f0c1cbb7d6112932cc188c6be007a5e2867005a069e47f42fe67bf5f122b0908"}, - {file = "pydantic_core-2.46.2-cp311-cp311-win32.whl", hash = "sha256:c1ce5b2366f85cfdbf7f0907755043707f86d09a5b1b1acebbb7bf1600d75c64"}, - {file = "pydantic_core-2.46.2-cp311-cp311-win_amd64.whl", hash = "sha256:f1a6197eadff5bd0bb932f12bb038d403cb75db5b0b391e70e816a647745ddaf"}, - {file = "pydantic_core-2.46.2-cp311-cp311-win_arm64.whl", hash = "sha256:15e42885b283f87846ee79e161002c5c496ef747a73f6e47054f45a13d9035bc"}, - {file = "pydantic_core-2.46.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ea1ad8c89da31512fe2d249cf0638fb666925bda341901541bc5f3311c6fcc9e"}, - {file = "pydantic_core-2.46.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b308da17b92481e0587244631c5529e5d91d04cb2b08194825627b1eca28e21e"}, - {file = "pydantic_core-2.46.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d333a50bdd814a917d8d6a7ee35ba2395d53ddaa882613bc24e54a9d8b129095"}, - {file = "pydantic_core-2.46.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1d00b99590c5bd1fabbc5d28b170923e32c1b1071b1f1de1851a4d14d89eb192"}, - {file = "pydantic_core-2.46.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9f0e686960ffe9e65066395af856ac2d52c159043144433602c50c221d81c1ba"}, - {file = "pydantic_core-2.46.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d1128da41c9cb474e0a4701f9c363ec645c9d1a02229904c76bf4e0a194fde2"}, - {file = "pydantic_core-2.46.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48649cf2d8c358d79586e9fb2f8235902fcaa2d969ec1c5301f2d1873b2f8321"}, - {file = "pydantic_core-2.46.2-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:b902f0fc7c2cf503865a05718b68147c6cd5d0a3867af38c527be574a9fa6e9d"}, - {file = "pydantic_core-2.46.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e80011f808b03d1d87a8f1e76ae3da19a18eb706c823e17981dcf1fae43744fc"}, - {file = "pydantic_core-2.46.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b839d5c802e31348b949b6473f8190cddbf7d47475856d8ac995a373ee16ec59"}, - {file = "pydantic_core-2.46.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:c6b1064f3f9cf9072e1d59dd2936f9f3b668bec1c37039708c9222db703c0d5b"}, - {file = "pydantic_core-2.46.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:37a68e6f2ac95578ce3c0564802404b27b24988649616e556c07e77111ed3f1d"}, - {file = "pydantic_core-2.46.2-cp312-cp312-win32.whl", hash = "sha256:d9ffa75a7ef4b97d6e5e205fabd4304ef01fec09e6f1bdde04b9ad1b07d20289"}, - {file = "pydantic_core-2.46.2-cp312-cp312-win_amd64.whl", hash = "sha256:0551f2d2ddb68af5a00e26497f8025c538f73ef3cb698f8e5a487042cd2792a8"}, - {file = "pydantic_core-2.46.2-cp312-cp312-win_arm64.whl", hash = "sha256:83aef30f106edcc21a6a4cc44b82d3169a1dbe255508db788e778f3c804d3583"}, - {file = "pydantic_core-2.46.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d26e9eea3715008a09a74585fe9becd0c67fbb145dc4df9756d597d7230a652c"}, - {file = "pydantic_core-2.46.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48b36e3235140510dc7861f0cd58b714b1cdd3d48f75e10ce52e69866b746f10"}, - {file = "pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36b1f99dc451f1a3981f236151465bcf995bbe712d0727c9f7b236fe228a8133"}, - {file = "pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8641c8d535c2d95b45c2e19b646ecd23ebba35d461e0ae48a3498277006250ab"}, - {file = "pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20fb194788a0a50993e87013e693494ba183a2af5b44e99cf060bbae10912b11"}, - {file = "pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9262d11d0cd11ee3303a95156939402bed6cedfe5ed0e331b95a283a4da6eb8b"}, - {file = "pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac204542736aa295fa25f713b7fad6fc50b46ab7764d16087575c85f085174f3"}, - {file = "pydantic_core-2.46.2-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9a7c43a0584742dface3ca0daf6f719d46c1ac2f87cf080050f9ae052c75e1b2"}, - {file = "pydantic_core-2.46.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd05e1edb6a90ad446fa268ab09e59202766b837597b714b2492db11ee87fab9"}, - {file = "pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:91155b110788b5501abc7ea954f1d08606219e4e28e3c73a94124307c06efb80"}, - {file = "pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e4e2c72a529fa03ff228be1d2b76944013f428220b764e03cc50ada67e17a42c"}, - {file = "pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:56291ec1a11c3499890c99a8fd9053b47e60fe837a77ec72c0671b1b8b3dce24"}, - {file = "pydantic_core-2.46.2-cp313-cp313-win32.whl", hash = "sha256:b50f9c5f826ddca1246f055148df939f5f3f2d0d96db73de28e2233f22210d4c"}, - {file = "pydantic_core-2.46.2-cp313-cp313-win_amd64.whl", hash = "sha256:251a57788823230ca8cbc99e6245d1a2ed6e180ec4864f251c94182c580c7f2e"}, - {file = "pydantic_core-2.46.2-cp313-cp313-win_arm64.whl", hash = "sha256:315d32d1a71494d6b4e1e14a9fa7a4329597b4c4340088ad7e1a9dafbeed92a9"}, - {file = "pydantic_core-2.46.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4f59b45f3ef8650c0c736a57f59031d47ed9df4c0a64e83796849d7d14863a2d"}, - {file = "pydantic_core-2.46.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a075a29ebef752784a91532a1a85be6b234ccffec0a9d7978a92696387c3da6"}, - {file = "pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d12d786e30c04a9d307c5d7080bf720d9bac7f1668191d8e37633a9562749e2"}, - {file = "pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d5e6d6343b0b5dcacb3503b5de90022968da8ed0ab9ab39d3eda71c20cbf84e"}, - {file = "pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:233eebac0999b6b9ba76eb56f3ec8fce13164aa16b6d2225a36a79e0f95b5973"}, - {file = "pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cc0eee720dd2f14f3b7c349469402b99ad81a174ab49d3533974529e9d93992"}, - {file = "pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83ee76bf2c9910513dbc19e7d82367131fa7508dedd6186a462393071cc11059"}, - {file = "pydantic_core-2.46.2-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:d61db38eb4ee5192f0c261b7f2d38e420b554df8912245e3546aee5c45e2fd78"}, - {file = "pydantic_core-2.46.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8f09a713d17bcd55da8ab02ebd9110c5246a49c44182af213b5212800af8bc83"}, - {file = "pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:30cacc5fb696e64b8ef6fd31d9549d394dd7d52760db072eecb98e37e3af1677"}, - {file = "pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:7ccfb105fcfe91a22bbb5563ad3dc124bc1aa75bfd2e53a780ab05f78cdf6108"}, - {file = "pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:13ffef637dc8370c249e5b26bd18e9a80a4fca3d809618c44e18ec834a7ca7a8"}, - {file = "pydantic_core-2.46.2-cp314-cp314-win32.whl", hash = "sha256:1b0ab6d756ca2704a938e6c31b53f290c2f9c10d3914235410302a149de1a83e"}, - {file = "pydantic_core-2.46.2-cp314-cp314-win_amd64.whl", hash = "sha256:99ebade8c9ada4df975372d8dd25883daa0e379a05f1cd0c99aa0c04368d01a6"}, - {file = "pydantic_core-2.46.2-cp314-cp314-win_arm64.whl", hash = "sha256:de87422197cf7f83db91d89c86a21660d749b3cd76cd8a45d115b8e675670f02"}, - {file = "pydantic_core-2.46.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:236f22b4a206b5b61db955396b7cf9e2e1ff77f372efe9570128ccfcd6a525eb"}, - {file = "pydantic_core-2.46.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c2012f64d2cd7cca50f49f22445aa5a88691ac2b4498ee0a9a977f8ca4f7289f"}, - {file = "pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d07d6c63106d3a9c9a333e2636f9c82c703b1a9e3b079299e58747964e4fdb72"}, - {file = "pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c326a2b4b85e959d9a1fc3a11f32f84611b6ec07c053e1828a860edf8d068208"}, - {file = "pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac8a65e798f2462552c00d2e013d532c94d646729dda98458beaf51f9ec7b120"}, - {file = "pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a3c2bc1cc8164bedbc160b7bb1e8cc1e8b9c27f69ae4f9ae2b976cdae02b2dd"}, - {file = "pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e69aa5e10b7e8b1bb4a6888650fd12fcbf11d396ca11d4a44de1450875702830"}, - {file = "pydantic_core-2.46.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4e6df5c3301e65fb42bc5338bf9a1027a02b0a31dc7f54c33775229af474daf0"}, - {file = "pydantic_core-2.46.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c2f6e32548ac8d559b47944effcf8ae4d81c161f6b6c885edc53bc08b8f192d"}, - {file = "pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:b089a81c58e6ea0485562bbbbbca4f65c0549521606d5ef27fba217aac9b665a"}, - {file = "pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:7f700a6d6f64112ae9193709b84303bbab84424ad4b47d0253301aabce9dfc70"}, - {file = "pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:67db6814beaa5fefe91101ec7eb9efda613795767be96f7cf58b1ca8c9ca9972"}, - {file = "pydantic_core-2.46.2-cp314-cp314t-win32.whl", hash = "sha256:32fbc7447be8e3be99bf7869f7066308f16be55b61f9882c2cefc7931f5c7664"}, - {file = "pydantic_core-2.46.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b317a2b97019c0b95ce99f4f901ae383f40132da6706cdf1731066a73394c25c"}, - {file = "pydantic_core-2.46.2-cp314-cp314t-win_arm64.whl", hash = "sha256:7dcb9d40930dfad7ab6b20bcc6ca9d2b030b0f347a0cd9909b54bd53ead521b1"}, - {file = "pydantic_core-2.46.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:33741359798f9dc3d4244a66031575d8a86c004f7853eb9961a49e4b6fab2d0b"}, - {file = "pydantic_core-2.46.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f557ce9106850c79252792962d78b987e11fcdc10e5c2252443b9a485d3bfe5"}, - {file = "pydantic_core-2.46.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd195af20e53aaac6cf5d7862e34dfdf86351720c858581ccb6563e02ae59421"}, - {file = "pydantic_core-2.46.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a8e486d238850ddf2b25739317b6551d5bef9925ab004b18c552ff6e645f8a2"}, - {file = "pydantic_core-2.46.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dfff584138be087457cc474791d082fdfe32b0d427613d5494a679fe9f4eaef5"}, - {file = "pydantic_core-2.46.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:387cbe2b2bcace397da91f9b1165a9e75da254bb306b876a43b824cc10f49ce0"}, - {file = "pydantic_core-2.46.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a6572f3238851fde28b3194ef98cec9dbe66f1614caf4646239ea87f324121a"}, - {file = "pydantic_core-2.46.2-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:b478652b580cd4cf7f2dd40dc9fde594ed1c84e5df4bafefffb8387ddb74049f"}, - {file = "pydantic_core-2.46.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b1c9bdca33968c0dcd875f8185b3b6275df753fe000178684b0c1738959f3cd"}, - {file = "pydantic_core-2.46.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e698fe2d8f75c4e9368ee3f4e0d3322d1180be2ec4592d3f73b2572765b1c705"}, - {file = "pydantic_core-2.46.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:404da669e5e02bf7fb2cc56715a609f63af88aea531287494467109f97865fe3"}, - {file = "pydantic_core-2.46.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:28708faed0b47f9d68906551a3471421ab0b15c31519e08fdb70ae6cad04d10b"}, - {file = "pydantic_core-2.46.2-cp39-cp39-win32.whl", hash = "sha256:5e2b4adb0fa46a842c492423e61063d6639cf9aea56380a02630ddcdd4894067"}, - {file = "pydantic_core-2.46.2-cp39-cp39-win_amd64.whl", hash = "sha256:fa8ab79cea8a1bfe52a21a9b37859c15478d009f242f47737201ecea885b9dd9"}, - {file = "pydantic_core-2.46.2-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:7c5a5b3dbb9e8918e223be6580da5ffcf861c0505bbc196ebed7176ce05b7b4e"}, - {file = "pydantic_core-2.46.2-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:bc1e8ce33d5a337f2ba862e0719b8201cd54aaed967406c748e009191d47efdd"}, - {file = "pydantic_core-2.46.2-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b737c0b280f41143266445de2689c0e49c79307e51c44ce3a77fef2bedad4994"}, - {file = "pydantic_core-2.46.2-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b877d597afb82b4898e35354bba55de6f7f048421ae0edadbb9886ec137b532"}, - {file = "pydantic_core-2.46.2-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:e9fcabd1857492b5bf16f90258babde50f618f55d046b1309972da2396321ff9"}, - {file = "pydantic_core-2.46.2-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:fb3ec2c7f54c07b30d89983ce78dc32c37dd06a972448b8716d609493802d628"}, - {file = "pydantic_core-2.46.2-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130a6c837d819ef33e8c2bf702ed2c3429237ea69807f1140943d6f4bdaf52fa"}, - {file = "pydantic_core-2.46.2-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2e25417cec5cd9bddb151e33cb08c50160f317479ecc02b22a95ec18f8fe004"}, - {file = "pydantic_core-2.46.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c3ad79ed32004d9de91cacd4b5faaff44d56051392fe1d5526feda596f01af25"}, - {file = "pydantic_core-2.46.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d157c48d28eebe5d46906de06a6a2f2c9e00b67d3e42de1f1b9c2d42b810f77c"}, - {file = "pydantic_core-2.46.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b42c6471288dedc979ac8400d9c9770f03967dd187db1f8d3405d4d182cc714"}, - {file = "pydantic_core-2.46.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4f27bc4801358dc070d6697b41237fce9923d8e69a1ce1e95606ac36c1552dc1"}, - {file = "pydantic_core-2.46.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e094a8f85db41aa7f6a45c5dac2950afc9862e66832934231962252b5d284eed"}, - {file = "pydantic_core-2.46.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:807eeda5551f6884d3b4421578be37be50ddb7a58832348e99617a6714a73748"}, - {file = "pydantic_core-2.46.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fcaa1c3c846a7f6686b38fe493d1b2e8007380e293bfef6a9354563c026cbf36"}, - {file = "pydantic_core-2.46.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:154dbfdfb11b8cbd8ff4d00d0b81e3d19f4cb4bedd5aa9f091060ba071474c6a"}, - {file = "pydantic_core-2.46.2.tar.gz", hash = "sha256:37bb079f9ee3f1a519392b73fda2a96379b31f2013c6b467fe693e7f2987f596"}, -] - -[package.dependencies] -typing-extensions = ">=4.14.1" - -[[package]] -name = "pydantic-settings" -version = "2.13.1" -description = "Settings management using Pydantic" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237"}, - {file = "pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025"}, -] - -[package.dependencies] -pydantic = ">=2.7.0" -python-dotenv = ">=0.21.0" -typing-inspection = ">=0.4.0" - -[package.extras] -aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] -azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] -gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] -toml = ["tomli (>=2.0.1)"] -yaml = ["pyyaml (>=6.0.1)"] - [[package]] name = "pygments" version = "2.20.0" @@ -734,21 +512,6 @@ pygments = ">=2.7.2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] -[[package]] -name = "python-dotenv" -version = "1.2.2" -description = "Read key-value pairs from a .env file and set them as environment variables" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"}, - {file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"}, -] - -[package.extras] -cli = ["click (>=5.0)"] - [[package]] name = "respx" version = "0.22.0" @@ -799,44 +562,13 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -[[package]] -name = "typing-inspection" -version = "0.4.2" -description = "Runtime typing introspection tools" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, - {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, -] - -[package.dependencies] -typing-extensions = ">=4.12.0" - -[[package]] -name = "win32-setctime" -version = "1.2.0" -description = "A small Python utility to set file creation time on Windows" -optional = false -python-versions = ">=3.5" -groups = ["main"] -markers = "sys_platform == \"win32\"" -files = [ - {file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"}, - {file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"}, -] - -[package.extras] -dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] - [metadata] lock-version = "2.1" python-versions = "^3.14" -content-hash = "e0ce0fdca0e07a184688c4bb010a6a3637cac55b67bab1c78a73e38c47a69637" +content-hash = "4671104b283afa43f806f6ba5586c4a3e89c55b29065d6b32e86bf2d29c2df37" diff --git a/pyproject.toml b/pyproject.toml index 4518dd7..5cbe193 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,10 +24,6 @@ classifiers=[ [tool.poetry.dependencies] python = "^3.14" httpx = "^0.28.1" -backoff = "^2.2.1" -loguru = "^0.7.3" -pydantic = "^2.10.4" -pydantic-settings = "^2.7.1" [tool.poetry.group.dev.dependencies] pytest = "^8.3.5" diff --git a/tests/conftest.py b/tests/conftest.py index 333a5dc..9d5517e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ import pytest -from tests.fake_transport import FakeTransport +from avito.testing import FakeTransport ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: diff --git a/tests/contracts/test_client_contracts.py b/tests/contracts/test_client_contracts.py index 36431d0..c341720 100644 --- a/tests/contracts/test_client_contracts.py +++ b/tests/contracts/test_client_contracts.py @@ -15,6 +15,7 @@ AutotekaVehicle, ) from avito.core import Transport +from avito.core.exceptions import ConfigurationError from avito.core.types import ApiTimeouts from avito.cpa import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead from avito.jobs import Application, JobDictionary, JobWebhook, Resume, Vacancy @@ -164,3 +165,14 @@ def test_auth_token_clients_use_explicit_sdk_timeouts() -> None: assert autoteka_timeout.pool == 3.0 client.close() + + +def test_closed_client_rejects_new_domain_factories() -> None: + client = AvitoClient( + AvitoSettings(auth=AuthSettings(client_id="client-id", client_secret="client-secret")) + ) + + client.close() + + with pytest.raises(ConfigurationError, match="Клиент закрыт"): + client.account() diff --git a/tests/contracts/test_public_surface.py b/tests/contracts/test_public_surface.py index 0cbb416..a3e58f9 100644 --- a/tests/contracts/test_public_surface.py +++ b/tests/contracts/test_public_surface.py @@ -2,13 +2,29 @@ import importlib import inspect -from dataclasses import fields, is_dataclass +from dataclasses import FrozenInstanceError, fields, is_dataclass from pathlib import Path +import pytest + import avito.autoteka as autoteka import avito.jobs as jobs import avito.orders as orders import avito.realty as realty +from avito import ( + AuthenticationError, + AuthorizationError, + AvitoError, + ConfigurationError, + ConflictError, + PaginatedList, + RateLimitError, + ResponseMappingError, + TransportError, + UnsupportedOperationError, + UpstreamApiError, + ValidationError, +) from avito.autoteka import ( AutotekaMonitoring, AutotekaReport, @@ -20,6 +36,7 @@ from avito.messenger import ChatMedia from avito.orders import DeliveryOrder, Order, OrderLabel, SandboxDelivery, Stock from avito.realty import RealtyBooking, RealtyListing, RealtyPricing +from avito.testing import FakeResponse, FakeTransport MODEL_MODULES = ( "avito.accounts.models", @@ -58,6 +75,26 @@ def test_removed_generic_request_wrappers_are_not_exported() -> None: assert "OrdersRequest" not in orders.__all__ +def test_top_level_package_exports_canonical_error_contract() -> None: + assert AvitoError.__module__ == "avito.core.exceptions" + assert TransportError.__module__ == "avito.core.exceptions" + assert ValidationError.__module__ == "avito.core.exceptions" + assert AuthenticationError.__module__ == "avito.core.exceptions" + assert AuthorizationError.__module__ == "avito.core.exceptions" + assert RateLimitError.__module__ == "avito.core.exceptions" + assert ConflictError.__module__ == "avito.core.exceptions" + assert UnsupportedOperationError.__module__ == "avito.core.exceptions" + assert UpstreamApiError.__module__ == "avito.core.exceptions" + assert ResponseMappingError.__module__ == "avito.core.exceptions" + assert ConfigurationError.__module__ == "avito.core.exceptions" + assert PaginatedList.__module__ == "avito.core.pagination" + + +def test_testing_package_exports_fake_transport_contract() -> None: + assert FakeTransport.__module__ == "avito.testing.fake_transport" + assert FakeResponse.__module__ == "httpx" + + def test_public_signatures_use_typed_requests_instead_of_generic_wrappers() -> None: methods = ( RealtyBooking.update_bookings_info, @@ -173,3 +210,40 @@ 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_section_clients_are_frozen_dataclasses() -> None: + module_names = ( + "avito.accounts.client", + "avito.ads.client", + "avito.autoteka.client", + "avito.cpa.client", + "avito.jobs.client", + "avito.messenger.client", + "avito.orders.client", + "avito.promotion.client", + "avito.ratings.client", + "avito.realty.client", + "avito.tariffs.client", + ) + 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 + if not is_dataclass(cls): + continue + params = getattr(cls, "__dataclass_params__", None) + if params is None or not params.frozen: + offenders.append(f"{module_name}.{cls.__name__}") + + assert offenders == [] + + +def test_avito_error_is_frozen_after_initialization() -> None: + error = AvitoError(message="Ошибка", metadata={"token": "secret"}) + + with pytest.raises(FrozenInstanceError): + error.message = "Другое сообщение" # type: ignore[misc] diff --git a/tests/core/test_authentication.py b/tests/core/test_authentication.py index 171fe89..c575804 100644 --- a/tests/core/test_authentication.py +++ b/tests/core/test_authentication.py @@ -90,7 +90,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) first_access_token = provider.get_access_token() - provider._access_token = replace( # type: ignore[attr-defined] + provider._access_token = replace( provider._access_token, # type: ignore[arg-type, attr-defined] expires_at=datetime.now(UTC) - timedelta(seconds=1), ) diff --git a/tests/core/test_configuration.py b/tests/core/test_configuration.py index 49c7c31..89c1064 100644 --- a/tests/core/test_configuration.py +++ b/tests/core/test_configuration.py @@ -12,6 +12,7 @@ ENV_KEYS = ( "AVITO_BASE_URL", "AVITO_USER_ID", + "AVITO_USER_AGENT_SUFFIX", "AVITO_AUTH__CLIENT_ID", "AVITO_AUTH__CLIENT_SECRET", "AVITO_AUTH__REFRESH_TOKEN", @@ -40,6 +41,7 @@ def test_avito_settings_from_env_reads_full_configuration( ( "AVITO_BASE_URL=https://sandbox.avito.ru", "AVITO_USER_ID=42", + "AVITO_USER_AGENT_SUFFIX=ci/contract-tests", "AVITO_AUTH__CLIENT_ID=client-id", "AVITO_AUTH__CLIENT_SECRET=client-secret", "AVITO_AUTH__REFRESH_TOKEN=refresh-token", @@ -51,6 +53,7 @@ def test_avito_settings_from_env_reads_full_configuration( assert settings.base_url == "https://sandbox.avito.ru" assert settings.user_id == 42 + assert settings.user_agent_suffix == "ci/contract-tests" assert settings.auth.client_id == "client-id" assert settings.auth.client_secret == "client-secret" assert settings.auth.refresh_token == "refresh-token" @@ -165,6 +168,7 @@ def test_process_environment_overrides_dotenv_and_parses_retry_options( "AVITO_TIMEOUT_READ=11", "AVITO_RETRY_MAX_ATTEMPTS=4", "AVITO_RETRY_BACKOFF_FACTOR=0.75", + "AVITO_RETRY_MAX_DELAY=9.5", "AVITO_RETRY_RETRYABLE_METHODS=GET,POST,PATCH", "AVITO_RETRY_RETRY_ON_RATE_LIMIT=false", "AVITO_RETRY_MAX_RATE_LIMIT_WAIT_SECONDS=12.5", @@ -184,6 +188,15 @@ def test_process_environment_overrides_dotenv_and_parses_retry_options( 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.max_delay == 9.5 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 + + +def test_avito_settings_rejects_secret_like_user_agent_suffix() -> None: + with pytest.raises(ConfigurationError, match="user_agent_suffix"): + AvitoSettings( + auth=AuthSettings(client_id="client-id", client_secret="client-secret"), + user_agent_suffix="secret=abc", + ).validate_required() diff --git a/tests/core/test_transport.py b/tests/core/test_transport.py index 58e778e..7c3da5b 100644 --- a/tests/core/test_transport.py +++ b/tests/core/test_transport.py @@ -1,5 +1,6 @@ from __future__ import annotations +import random as random_module from collections.abc import Iterator from datetime import UTC, datetime, timedelta @@ -26,16 +27,19 @@ ) from avito.core.retries import RetryPolicy from avito.core.types import ApiTimeouts +from avito.testing import FakeTransport def make_settings( *, retry_policy: RetryPolicy | None = None, timeouts: ApiTimeouts | None = None, + user_agent_suffix: str | None = None, ) -> AvitoSettings: return AvitoSettings( base_url="https://api.avito.ru", auth=AuthSettings(client_id="client-id", client_secret="client-secret"), + user_agent_suffix=user_agent_suffix, retry_policy=retry_policy or RetryPolicy(), timeouts=timeouts or ApiTimeouts(), ) @@ -64,6 +68,46 @@ def handler(request: httpx.Request) -> httpx.Response: assert calls["count"] == 2 +def test_transport_sends_user_agent_on_every_request() -> None: + fake_transport = FakeTransport() + fake_transport.add_json("GET", "/items", {"ok": True}) + fake_transport.add_json("POST", "/items", {"created": True}) + transport = fake_transport.build() + + transport.request_json("GET", "/items", context=RequestContext("list_items")) + transport.request_json( + "POST", + "/items", + context=RequestContext("create_item", allow_retry=True), + json_body={"name": "item"}, + ) + + assert len(fake_transport.requests) == 2 + for request in fake_transport.requests: + user_agent = request.headers.get("user-agent") + assert user_agent is not None + assert user_agent.startswith("avito-py/") + assert "python/" in user_agent + assert "httpx/" in user_agent + + +def test_transport_appends_user_agent_suffix() -> None: + fake_transport = FakeTransport() + fake_transport.add_json("GET", "/items", {"ok": True}) + transport = Transport( + make_settings(user_agent_suffix="ci/transport-tests"), + client=httpx.Client( + transport=httpx.MockTransport(fake_transport._handle), + base_url=fake_transport.base_url, + ), + sleep=lambda _: None, + ) + + transport.request_json("GET", "/items", context=RequestContext("list_items")) + + assert fake_transport.last(path="/items").headers["user-agent"].endswith("ci/transport-tests") + + def test_transport_refreshes_token_after_401() -> None: issued_tokens: Iterator[AccessToken] = iter( ( @@ -99,6 +143,21 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen_authorization_headers == ["Bearer expired-token", "Bearer fresh-token"] +def test_retry_policy_uses_full_jitter_with_cap() -> None: + policy = RetryPolicy( + backoff_factor=2.0, + max_delay=3.0, + random_source=random_module.Random(7), + ) + + first_delay = policy.compute_backoff(3) + second_delay = policy.compute_backoff(3) + + assert 0.0 <= first_delay <= 3.0 + assert 0.0 <= second_delay <= 3.0 + assert first_delay != second_delay + + def test_transport_does_not_retry_non_idempotent_request_without_explicit_permission() -> None: calls = {"count": 0} @@ -120,6 +179,64 @@ def handler(request: httpx.Request) -> httpx.Response: assert calls["count"] == 1 +def test_transport_retries_post_with_same_idempotency_key_for_whole_retry_chain() -> None: + calls = {"count": 0} + seen_keys: list[str | None] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls["count"] += 1 + seen_keys.append(request.headers.get("Idempotency-Key")) + if calls["count"] == 1: + raise httpx.ConnectError("offline", request=request) + return httpx.Response(200, json={"ok": True}) + + transport = Transport( + make_settings(), + client=httpx.Client( + transport=httpx.MockTransport(handler), base_url="https://api.avito.ru" + ), + sleep=lambda _: None, + ) + + payload = transport.request_json( + "POST", + "/items", + context=RequestContext("create_item", allow_retry=True), + json_body={"name": "item"}, + idempotency_key="idem-123", + ) + + assert payload == {"ok": True} + assert calls["count"] == 2 + assert seen_keys == ["idem-123", "idem-123"] + + +def test_transport_does_not_retry_post_without_idempotency_key_even_with_allow_retry() -> None: + calls = {"count": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["count"] += 1 + raise httpx.ConnectError("offline", request=request) + + transport = Transport( + make_settings(), + client=httpx.Client( + transport=httpx.MockTransport(handler), base_url="https://api.avito.ru" + ), + sleep=lambda _: None, + ) + + with pytest.raises(Exception, match="offline"): + transport.request_json( + "POST", + "/items", + context=RequestContext("create_item", allow_retry=True), + json_body={"name": "item"}, + ) + + assert calls["count"] == 1 + + @pytest.mark.parametrize( ("status_code", "error_cls"), ( diff --git a/tests/domains/ads/test_ads.py b/tests/domains/ads/test_ads.py index 3a9b72c..213b9e6 100644 --- a/tests/domains/ads/test_ads.py +++ b/tests/domains/ads/test_ads.py @@ -1,11 +1,14 @@ from __future__ import annotations import json +import logging from datetime import datetime import httpx +import pytest from avito.ads import Ad, AdPromotion, AdStats +from avito.ads.enums import ListingStatus from tests.helpers.transport import make_transport @@ -106,3 +109,34 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen_payloads[0]["dateFrom"] == "2026-04-18T00:00:00+03:00" assert seen_payloads[0]["dateTo"] == "2026-04-18T23:59:59+03:00" + + +def test_ads_unknown_enum_maps_to_unknown_and_warns_once(caplog: pytest.LogCaptureFixture) -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/core/v1/accounts/7/items/101/" + return httpx.Response( + 200, + json={ + "id": 101, + "user_id": 7, + "title": "Смартфон", + "price": 1000, + "status": "mystery-status", + }, + ) + + caplog.set_level(logging.WARNING, logger="avito.core.enums") + ad = Ad(make_transport(httpx.MockTransport(handler)), item_id=101, user_id=7) + + first = ad.get() + second = ad.get() + + assert first.status is ListingStatus.UNKNOWN + assert second.status is ListingStatus.UNKNOWN + records = [ + record + for record in caplog.records + if getattr(record, "enum", None) == "ads.listing_status" + and getattr(record, "value", None) == "mystery-status" + ] + assert len(records) == 1 diff --git a/tests/domains/cpa/test_cpa.py b/tests/domains/cpa/test_cpa.py index 2244da1..83f94b2 100644 --- a/tests/domains/cpa/test_cpa.py +++ b/tests/domains/cpa/test_cpa.py @@ -5,11 +5,6 @@ import httpx from avito.cpa import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead -from avito.cpa.models import ( - CpaChatsByTimeRequest, - CpaLeadComplaintRequest, - CpaPhonesFromChatsRequest, -) from tests.helpers.transport import make_transport @@ -28,9 +23,9 @@ def handler(request: httpx.Request) -> httpx.Response: 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" + assert chat.list(created_at_from="2026-04-18T00:00:00+03:00", version=1).items[0].buyer_name == "Петр" + assert chat.list(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(action_ids=["act-1", "act-2"]).items[1].phone_number == "+79990000002" def test_cpa_calls_archive_and_balance_flows() -> None: @@ -59,7 +54,7 @@ def handler(request: httpx.Request) -> httpx.Response: 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.create_complaint_by_action_id(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(call_id=2001).call_id == "2001" diff --git a/tests/domains/jobs/test_jobs.py b/tests/domains/jobs/test_jobs.py index e1c22a4..1d56c00 100644 --- a/tests/domains/jobs/test_jobs.py +++ b/tests/domains/jobs/test_jobs.py @@ -5,13 +5,8 @@ from avito.jobs import Application, JobDictionary, JobWebhook, Resume, Vacancy from avito.jobs.models import ( ApplicationIdsQuery, - ApplicationIdsRequest, ApplicationViewedItem, ResumeSearchQuery, - VacancyArchiveRequest, - VacancyAutoRenewalRequest, - VacancyProlongateRequest, - VacancyUpdateRequest, ) from tests.helpers.transport import make_transport @@ -49,7 +44,7 @@ def handler(request: httpx.Request) -> httpx.Response: 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.list(ids=["app-1"]).items[0].applicant_name == "Иван" assert application.get_states().items[0].slug == "new" assert application.update(applies=[ApplicationViewedItem(id="app-1", is_viewed=True)]).status == "viewed" assert application.apply(ids=["app-1"], action="invited").status == "invited" @@ -96,15 +91,15 @@ def handler(request: httpx.Request) -> httpx.Response: dictionary = JobDictionary(transport, dictionary_id="profession") 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.update(title="Старший продавец", version=1).status == "updated" + assert vacancy.delete(employee_id=7).status == "archived" + assert vacancy.prolongate(billing_type="package").status == "prolongated" assert vacancy.list().items[0].uuid == "vac-uuid-1" 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.update(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 vacancy.update_auto_renewal(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/promotion/test_promotion.py b/tests/domains/promotion/test_promotion.py index b024fec..9fa5dee 100644 --- a/tests/domains/promotion/test_promotion.py +++ b/tests/domains/promotion/test_promotion.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging from datetime import datetime import httpx @@ -16,6 +17,7 @@ TargetActionPricing, TrxPromotion, ) +from avito.promotion.enums import PromotionStatus, TargetActionBudgetType, TargetActionSelectedType from avito.promotion.models import ( BbipItem, ) @@ -205,6 +207,132 @@ def handler(request: httpx.Request) -> httpx.Response: assert trx_apply.request_payload == trx_preview.request_payload +def test_promotion_dry_run_does_not_call_transport() -> None: + seen_paths: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_paths.append(request.url.path) + return httpx.Response(200, json={"items": []}) + + transport = make_transport(httpx.MockTransport(handler)) + bbip = BbipPromotion(transport, item_id=101) + trx = TrxPromotion(transport, item_id=101) + 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"), + } + + bbip.create_order(items=[bbip_item], dry_run=True) + trx.apply(items=[trx_item], dry_run=True) + pricing.update_manual(action_type_id=5, bid_penny=1500, dry_run=True) + + assert seen_paths == [] + + +def test_promotion_unknown_enums_map_to_unknown_and_warn_once( + caplog: pytest.LogCaptureFixture, +) -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/promotion/v1/items/services/get": + return httpx.Response( + 200, + json={ + "items": [ + { + "itemId": 101, + "serviceCode": "x2", + "serviceName": "X2", + "price": 9900, + "status": "mystery-promotion-status", + } + ] + }, + ) + assert request.url.path == "/cpxpromo/1/getBids/101" + return httpx.Response( + 200, + json={ + "actionTypeID": 5, + "selectedType": "mystery-selected-type", + "auto": { + "budgetPenny": 1000, + "budgetType": "mystery-budget-type", + "dailyBudget": {"budgets": []}, + "weeklyBudget": {"budgets": []}, + "monthlyBudget": {"budgets": []}, + }, + }, + ) + + caplog.set_level(logging.WARNING, logger="avito.core.enums") + transport = make_transport(httpx.MockTransport(handler)) + order = PromotionOrder(transport, order_id="ord-1") + pricing = TargetActionPricing(transport, item_id=101) + + first_service = order.list_services(item_ids=[101]).items[0] + second_service = order.list_services(item_ids=[101]).items[0] + first_bids = pricing.get_bids() + second_bids = pricing.get_bids() + + assert first_service.status is PromotionStatus.UNKNOWN + assert second_service.status is PromotionStatus.UNKNOWN + assert first_bids.selected_type is TargetActionSelectedType.UNKNOWN + assert second_bids.selected_type is TargetActionSelectedType.UNKNOWN + assert first_bids.auto is not None + assert second_bids.auto is not None + assert first_bids.auto.budget_type is TargetActionBudgetType.UNKNOWN + assert second_bids.auto.budget_type is TargetActionBudgetType.UNKNOWN + + status_records = [ + record + for record in caplog.records + if getattr(record, "enum", None) == "promotion.status" + and getattr(record, "value", None) == "mystery-promotion-status" + ] + selected_type_records = [ + record + for record in caplog.records + if getattr(record, "enum", None) == "promotion.target_action_selected_type" + and getattr(record, "value", None) == "mystery-selected-type" + ] + budget_type_records = [ + record + for record in caplog.records + if getattr(record, "enum", None) == "promotion.target_action_budget_type" + and getattr(record, "value", None) == "mystery-budget-type" + ] + assert len(status_records) == 1 + assert len(selected_type_records) == 1 + assert len(budget_type_records) == 1 + + +def test_idempotency_key_forwarded_once_per_retry_chain() -> None: + calls = {"count": 0} + seen_keys: list[str | None] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls["count"] += 1 + seen_keys.append(request.headers.get("Idempotency-Key")) + if calls["count"] == 1: + raise httpx.ConnectError("offline", request=request) + return httpx.Response(200, json={"items": [{"itemID": 101, "success": True, "status": "manual"}]}) + + transport = make_transport(httpx.MockTransport(handler)) + pricing = TargetActionPricing(transport, item_id=101) + + result = pricing.update_manual( + action_type_id=5, + bid_penny=1500, + idempotency_key="idem-123", + ) + + assert result.status == "manual" + assert seen_keys == ["idem-123", "idem-123"] + + 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": diff --git a/tests/domains/realty/test_realty.py b/tests/domains/realty/test_realty.py index e8d4472..b8d39ec 100644 --- a/tests/domains/realty/test_realty.py +++ b/tests/domains/realty/test_realty.py @@ -6,11 +6,8 @@ from avito.realty import RealtyAnalyticsReport, RealtyBooking, RealtyListing, RealtyPricing from avito.realty.models import ( - RealtyBaseParamsUpdateRequest, - RealtyBookingsUpdateRequest, RealtyInterval, RealtyPricePeriod, - RealtyPricesUpdateRequest, ) from tests.helpers.transport import make_transport @@ -43,12 +40,12 @@ def handler(request: httpx.Request) -> httpx.Response: 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 + assert booking.update_bookings_info(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 pricing.update_realty_prices(periods=[RealtyPricePeriod(date_from="2026-05-01", price=5000)]).status == "success" 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 listing.update_base_params(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/fake_transport.py b/tests/fake_transport.py index 37c8bbd..05d8ae8 100644 --- a/tests/fake_transport.py +++ b/tests/fake_transport.py @@ -1,157 +1,28 @@ 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 +from warnings import warn + +from avito.testing.fake_transport import ( + FakeResponse, + FakeTransport, + JsonValue, + RecordedRequest, + json_response, + route_sequence, +) + +warn( + "Импорт из tests.fake_transport устарел; используйте avito.testing.fake_transport " + "или avito.testing.", + DeprecationWarning, + stacklevel=2, +) + +__all__ = ( + "FakeResponse", + "FakeTransport", + "JsonValue", + "RecordedRequest", + "json_response", + "route_sequence", +)