diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2d04348..2277b12 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -48,7 +48,12 @@ "Bash(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 \"https://developers.avito.ru/api-catalog/messenger/swagger.json\")", "Bash(curl -s --max-time 10 \"https://developers.avito.ru/swagger/messenger.yaml\")", "Bash(curl -s --max-time 10 \"https://developers.avito.ru/api-catalog/messenger/swagger.json\")", - "Bash(awk -F'|' '{print $15}')" + "Bash(awk -F'|' '{print $15}')", + "Bash(git rm *)", + "Bash(grep -E \"\\\\.json$|\\\\.lock$|todo\\\\.md|action_plan\\\\.md|usability_scorecard\\\\.md\")", + "Bash(echo \"---EXISTS:$?\")", + "Bash(git mv *)", + "Bash(make swagger-lint *)" ] } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6339a1..248a6bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,11 +31,8 @@ jobs: - name: Install dependencies run: poetry install --no-interaction --with docs - - name: Run strict Swagger coverage gate - run: make swagger-coverage - - name: Run quality gate - run: make check + run: make quality - name: Run docs strict gate run: make docs-strict diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13e08a6..6953627 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,15 +37,9 @@ jobs: TAG_VERSION="${GITHUB_REF_NAME#v}" poetry version "$TAG_VERSION" - - name: Run strict Swagger coverage gate - run: make swagger-coverage - - name: Run quality gate run: make check - - name: Build package to PyPi - run: make build - - name: Publish package to PyPI env: POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore index ce5988e..a8de6ba 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,8 @@ pip-delete-this-directory.txt # Unit test / coverage reports swagger-bindings-report.json +architecture-inventory-report.json +reference-explanation-examples-report.json htmlcov/ .tox/ .nox/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f6b8fb..01b5468 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,10 @@ and this project adheres to Semantic Versioning. ## [Unreleased] ### Added -- Нет изменений. +- Добавлен `ClientClosedError` для вызовов после `AvitoClient.close()`. ### Deprecated +- Env alias `AVITO_SECRET` для `AVITO_CLIENT_SECRET` устарел и теперь эмитирует `DeprecationWarning`; используйте `AVITO_CLIENT_SECRET`. - Архивные CPA-методы `CpaArchive.get_call`, `CpaArchive.get_balance_info`, `CpaArchive.get_call_by_id` и режим `CpaChat.list(version=1)` теперь эмитируют `DeprecationWarning` при первом вызове; используйте `call_tracking_call().download`, `cpa_lead().get_balance_info`, `call_tracking_call().get` и `cpa_chat().list(version=2)`. - Архивные методы автозагрузки `AutoloadArchive.get_profile`, `AutoloadArchive.save_profile`, `AutoloadArchive.get_last_completed_report`, `AutoloadArchive.get_report` теперь эмитируют `DeprecationWarning` при первом вызове; используйте `autoload_profile().get`, `autoload_profile().save`, `autoload_report().get_last_completed` и `autoload_report().get`. @@ -19,12 +20,34 @@ and this project adheres to Semantic Versioning. - Убраны прямые обращения доменных клиентов к `request_json` и приватному `Transport._auth_provider`. - Секционные клиенты переведены на `@dataclass(slots=True, frozen=True)`. - Иерархия исключений упрощена до frozen dataclass без кастомного `__setattr__`. +- `AvitoClient.settings`, `AvitoClient.auth_provider` и `AvitoClient.transport` стали read-only свойствами; для тестов используйте `AvitoClient._from_transport(...)`. +- OAuth token flow переведен на общий `Transport` без прямого `httpx.Client().post(...)`; ошибки OAuth продолжают приходить как `AuthenticationError`. - Публичные сигнатуры `accounts`, `ads`, `autoteka`, `cpa`, `jobs`, `messenger`, `orders`, `promotion`, `ratings` и `realty` переведены с `request`-DTO на keyword-only примитивы и коллекции. +- Swagger-bound public methods теперь принимают per-operation overrides `timeout` и `retry`; `retry="enabled"` форсирует retry, `retry="disabled"` запрещает retry для конкретного вызова. +- `Review.list`, `AutotekaMonitoring.get_monitoring_reg_actions`, `Vacancy.list`, `Vacancy.get`, `Application.get_ids` и `Resume.list` больше не принимают internal query DTO в публичных сигнатурах; передавайте `page`, `offset`, `limit`, `query`, `vacancy_id` и `updated_at_from` напрямую. +- Promotion input модели `BbipItem`, `TrxItem` и `CpaAuctionBidInput`, а также jobs-модель `ApplicationViewedItem` оформлены как публичные frozen dataclass-модели без наследования от internal `RequestModel`. - 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-значение. +- `CpaCallStatusId` получил `UNKNOWN`; неизвестный upstream `statusId` больше не превращается в `None` и логирует warning один раз на процесс. +- **BREAKING:** `AccountHierarchy.list_items_by_employee(...)` теперь требует `category_id` и отправляет Swagger body `employeeId/categoryId/lastItemId`; старые `limit`/`offset` не входят в контракт `/listItemsByEmployeeIdV1`. +- **BREAKING:** статистические методы `AdStats.get_item_stats(...)`, `get_calls_stats(...)`, `get_item_analytics(...)` и `get_account_spendings(...)` теперь требуют обязательные поля периода и параметры, описанные в Swagger requestBody. +- **BREAKING:** `AdPromotion.apply_vas(...)` принимает `vas_id` для legacy v1 endpoint, а `apply_vas_direct(...)` принимает `slugs`; payload больше не использует внутренний ключ `codes`. +- **BREAKING:** CPA methods now match Swagger request bodies: complaints send `message`, balanceInfo sends JSON string `"{}"`, chats/phones/calls list methods require `limit`/`offset` or `limit` fields declared by Swagger. +- **BREAKING:** Autoteka request bodies now match Swagger: `get_leads(...)` requires `subscription_id`, catalog resolve sends `fieldsValueIds`, monitoring bucket methods send `data`, and vehicle/request identifiers use Swagger JSON types. +- **BREAKING:** Autoload profile saves now require Swagger fields (`report_email`, schedule and feed/upload URL), stock info sends `item_ids`, TrxPromo cancel sends `itemIDs`, and Autostrategy update/stop generated calls include `campaignId` and `version`. +- **BREAKING:** Jobs vacancy write methods now require Swagger billing fields, classic v1 vacancy create requires the documented required fields, `JobWebhook.update(...)` requires `secret`, and vacancy statuses send UUID string ids. +- **BREAKING:** Messenger request bodies now match Swagger for blacklist, text messages and image messages; malformed Swagger required fields absent from schema properties are ignored by the normalized schema tree. +- **BREAKING:** Special-offers request bodies now match Swagger: `create_multi(...)` sends only `itemIds`, `confirm_multi(...)` sends `dispatches`/`expiresAt`, and `get_stats(...)` requires `date_time_from`/`date_time_to`. +- Вызовы после `AvitoClient.close()` теперь поднимают `ClientClosedError` вместо `ConfigurationError`. ### Removed -- Нет изменений. +- **BREAKING:** удалены классы исключений `NotFoundError`, `ClientError`, `ServerError` из `avito.core.exceptions`. HTTP 404 и 5xx теперь маппятся на `UpstreamApiError`. Пользователям, ловившим эти типы, перейти на `UpstreamApiError` или `AvitoError` и проверять `status_code`. +- **BREAKING:** удален публичный wrapper `Application.list(...)`; используйте `application().get_ids(updated_at_from=...)` для синхронизации id и `application().get_by_ids(ids=...)` для получения данных откликов. +- **BREAKING:** internal query DTO `ApplicationIdsQuery`, `ResumeSearchQuery`, `VacanciesQuery` и `MonitoringEventsQuery` больше не re-export-ятся из доменных пакетов; публичные методы принимают primitive keyword-only параметры. +- **BREAKING:** удалены старые public input aliases `BbipItemInput`, `TrxItemInput` и `BidItemInput`; используйте `BbipItem`, `TrxItem` и `CpaAuctionBidInput`. +- Удалены legacy-модули `avito/auth/mappers.py` и `avito/auth/enums.py` (внутренние, без публичных импортов). +- Удалены инфраструктурные мета-тесты (`tests/docs/`, `tests/test_inventory_architecture.py`, `tests/test_download_avito_api_specs.py`, `tests/core/test_architecture_lint.py`, `tests/core/test_swagger_{linter,discovery,factory_map,report}.py`); архитектурные инварианты остаются под `make swagger-lint` и `make architecture-lint`. +- Удалены архитектурные тесты `tests/contracts/test_public_surface.py` и `tests/core/test_swagger.py` — публичная поверхность и метаданные `@swagger_operation` верифицируются `mypy strict` + `make swagger-lint` + `tests/contracts/test_swagger_contracts.py`. ### Fixed - Нет изменений. diff --git a/CLAUDE.md b/CLAUDE.md index 7fd7c87..8cc5986 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,15 +26,17 @@ poetry run pytest tests/test_facade.py::test_name | Layer | Location | Responsibility | |---|---|---| | `AvitoClient` | `avito/client.py` | Public facade, factory methods | -| `SectionClient` | `avito//client.py` | HTTP calls for one API section | +| `DomainObject` | `avito//domain.py` | Explicit public methods, validation, docstrings, Swagger bindings | +| `OperationSpec` | `avito//operations.py` or `operations/` | Internal HTTP method/path/context/retry/request/response metadata | +| `OperationExecutor` | `avito/core/operations.py` | Executes operation specs through transport | | `Transport` | `avito/core/transport.py` | httpx, retries, error mapping, token injection | | `AuthProvider` | `avito/auth/provider.py` | Token cache, refresh, 401 handling | -| `Mapper` | `avito//mappers.py` | JSON → typed dataclass | +| Models | `avito//models.py` or `models/` | dataclasses, enums, `from_payload()`, `to_payload()`, `to_params()` | | Config | `avito/config.py`, `avito/auth/settings.py` | `AvitoSettings`, `AuthSettings` | -**Domain packages** follow a uniform structure: `__init__.py`, `domain.py` (DomainObject subclass), `client.py` (SectionClient), `models.py` (frozen dataclasses), `mappers.py`, optional `enums.py`. +**Target domain packages** follow the v2 architecture in `docs/site/explanations/domain-architecture-v2.md`: `__init__.py`, `domain.py` (DomainObject subclasses), `operations.py` or `operations/` (OperationSpec definitions), and `models.py` or `models/` (frozen dataclasses, colocated enums, payload parsing/serialization). Do not add new `client.py`, `mappers.py`, or standalone `enums.py` without an explicit architecture reason. Existing legacy modules may remain during migration; compatibility mappers should delegate to `Model.from_payload()`. -**Public models** are `@dataclass(slots=True, frozen=True)`, inherit `SerializableModel` (provides `to_dict()` / `model_dump()`), and never expose transport fields. +**Public models** are `@dataclass(slots=True, frozen=True)`, inherit `ApiModel` or another approved `SerializableModel` subclass (provides `to_dict()` / `model_dump()`), implement `from_payload()` for API JSON, and never expose transport fields. Request/query dataclasses use `to_payload()` / `to_params()` and stay out of public method signatures unless explicitly documented as public input models. **Exceptions** live in `avito/core/exceptions.py`. `AvitoError` is the base. HTTP codes map to specific types: 401→`AuthenticationError`, 403→`AuthorizationError`, 429→`RateLimitError`, etc. These two are siblings, not parent/child. @@ -65,7 +67,7 @@ Multiple Swagger bindings on one public SDK method are forbidden. If one public When adding or changing a public method that corresponds to Avito API: - consult `docs/avito/api/*.json` first; -- add or update the public domain method, section client call, mapper and typed public models; +- add or update the public domain method, operation spec, typed models, request/query dataclasses, and `from_payload()` / `to_payload()` / `to_params()` mapping; - add `@swagger_operation(...)` on the public domain method; - do not put schemas, statuses, content types, request models, response models, error models, path params, or query params into the decorator; - add or update class-level Swagger metadata when introducing a domain class; @@ -96,6 +98,7 @@ The most critical prohibitions that must never be violated: - Using `resource_id` instead of concrete names (`item_id`, `order_id`). - Annotating `list[T]` where `PaginatedList[T]` is returned at runtime. - Adding or changing an Avito API public method without a `@swagger_operation(...)` binding. +- Adding or changing an Avito API public method without a matching `OperationSpec` or documented legacy adapter. - Adding or changing an Avito API public method without a reference-ready docstring. - Duplicating Swagger contract data inside binding decorators. - Making `AuthenticationError` a subclass of `AuthorizationError` (or vice versa). @@ -103,11 +106,15 @@ The most critical prohibitions that must never be violated: - Injecting methods via `setattr`/`globals()` at runtime. - Duplicating behavior through two different public methods without deprecation. - Leaking internal-layer request-DTOs into public signatures. +- Adding new `client.py`, `mappers.py`, or standalone `enums.py` in a new/refactored domain without an explicit architecture note. - Adding dead code: unused imports, type aliases, TypeVars. ## Key conventions (from STYLEGUIDE.md) - All public methods return typed SDK models, never raw `dict`. +- New/refactored domains use v2 layout: `domain.py`, `operations.py`/`operations/`, `models.py`/`models/`. +- API response parsing belongs in `Model.from_payload()`; request/query serialization belongs in dataclasses via `to_payload()` / `to_params()`. +- Enums live next to the models that use them; legacy `enums.py` modules are compatibility re-exports only. - Field names are concrete: `item_id`, `user_id` — never `resource_id`. - Public method arguments are primitives or domain models — internal request-DTOs must not leak out. - Write-operations that accept `dry_run: bool = False` must build the same payload in both modes; with `dry_run=True` transport must not be called. diff --git a/Makefile b/Makefile index eb73b55..4a0f6c1 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,9 @@ export REGISTRY=10.11.0.9:5000 MKDOCS_ENV=DISABLE_MKDOCS_2_WARNING=true NO_MKDOCS_2_WARNING=1 -check: swagger-update test typecheck lint swagger-lint build +check: test quality + +quality: typecheck lint swagger-lint architecture-lint docstring-lint build build: clean poetry build @@ -36,8 +38,14 @@ swagger-update: swagger-lint: swagger-update poetry run python scripts/lint_swagger_bindings.py --strict +architecture-lint: + poetry run python scripts/lint_architecture.py + +docstring-lint: + poetry run python scripts/lint_docstrings.py + swagger-coverage: swagger-lint - poetry run pytest tests/core/test_swagger.py tests/core/test_swagger_discovery.py tests/core/test_swagger_linter.py tests/core/test_swagger_report.py tests/core/test_swagger_registry.py tests/contracts/test_swagger_contracts.py + poetry run pytest tests/core/test_swagger_registry.py tests/contracts/test_swagger_contracts.py minor: check poetry version minor @@ -57,7 +65,7 @@ docs-serve: docs-strict: $(MKDOCS_ENV) poetry run mkdocs build --strict poetry run python scripts/lint_swagger_bindings.py --strict - poetry run pytest tests/docs/ + poetry run python scripts/lint_docstrings.py docs-build: docs-strict diff --git a/README.md b/README.md index f4893c7..8377fff 100644 --- a/README.md +++ b/README.md @@ -118,10 +118,13 @@ with AvitoClient.from_env() as avito: account = avito.account(user_id=123) balance = account.get_balance() ad = avito.ad(item_id=42, user_id=123).get() - stats = avito.ad_stats(item_id=42, user_id=123).get_item_stats() + stats = avito.ad_stats(item_id=42, user_id=123).get_item_stats( + date_from="2026-04-01", + date_to="2026-04-23", + ) ``` -`user_id` можно передать явно, задать через `AVITO_USER_ID` или оставить пустым для read-only вызовов, где SDK может определить пользователя через `account().get_self()`. Если идентификатор не удалось определить, SDK поднимает `ValidationError` с подсказкой, как вызвать метод правильно. Для OAuth secret поддерживаются `AVITO_CLIENT_SECRET` и alias `AVITO_SECRET`. +`user_id` можно передать явно, задать через `AVITO_USER_ID` или оставить пустым для read-only вызовов, где SDK может определить пользователя через `account().get_self()`. Если идентификатор не удалось определить, SDK поднимает `ValidationError` с подсказкой, как вызвать метод правильно. Для OAuth secret используйте `AVITO_CLIENT_SECRET`. Статистические методы принимают `date`, `datetime` и ISO-строки, а в Avito API отправляют дату в формате `YYYY-MM-DD`. Модель `Listing` нормализует основные поля объявления: `title`, `price`, `status`, `description`, `url`, `category`, `city`, `published_at`, `updated_at`, `is_moderated`, `is_visible`. @@ -206,14 +209,12 @@ with AvitoClient.from_env() as avito: ```python from avito import AvitoClient -from avito.jobs import ApplicationIdsQuery, ResumeSearchQuery with AvitoClient.from_env() as avito: - vacancies = avito.vacancy().list() - applications = avito.application().list( - query=ApplicationIdsQuery(updated_at_from="2026-04-18") - ) - resumes = avito.resume().list(query=ResumeSearchQuery(query="оператор")) + vacancies = avito.vacancy().list(query="python") + application_ids = avito.application().get_ids(updated_at_from="2026-04-18") + applications = avito.application().get_by_ids(ids=[application_ids.items[0].id]) + resumes = avito.resume().list(query="оператор") webhooks = avito.job_webhook().list() ``` @@ -225,7 +226,7 @@ from avito import AvitoClient with AvitoClient.from_env() as avito: calls = avito.cpa_call().list( date_time_from="2026-04-18T00:00:00Z", - date_time_to="2026-04-19T00:00:00Z", + limit=100, ) calltracking = avito.call_tracking_call(10).get() records = avito.call_tracking_call(10).download() @@ -260,7 +261,7 @@ with AvitoClient.from_env() as avito: tariff = avito.tariff().get_tariff_info() ``` -`review().list()` по умолчанию запрашивает первую страницу отзывов (`page=1`, `limit=50`). Для явной пагинации передайте `ReviewsQuery(page=..., limit=...)`. +`review().list()` по умолчанию запрашивает первую страницу отзывов (`page=1`, `limit=50`). Для явной пагинации передайте `page`, `offset` или `limit` напрямую. ## Пагинация @@ -340,6 +341,7 @@ make fmt make lint make typecheck make test +make quality make build ``` @@ -347,7 +349,7 @@ make build Для репозитория настроены два workflow: -- `CI` запускается на каждый `push` в `main`/`master` и на каждый `pull_request`, выполняет `make check`. +- `CI` запускается на каждый `push` в `main`/`master` и на каждый `pull_request`, выполняет `make quality`, docs-gates и один полный pytest-прогон через coverage. - `Release` запускается при пуше тега вида `v*`, выставляет версию пакета из тега, повторно выполняет `make check`, публикует пакет на PyPI и создаёт GitHub Release. Для публикации релиза нужно добавить secret: diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index d7162dc..c1114b2 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -23,7 +23,7 @@ Principles are listed in descending priority order when they conflict. - For each task there must be one obvious way — not two, not three. - Errors must not pass silently: invalid state is detected as early as possible. - The public API of the library must be simple; internal details must be encapsulated. -- Each layer is responsible for its own task only: transport, auth, API clients, domain models, mapping, errors. +- Each layer is responsible for its own task only: transport, auth, operation execution, domain models, mapping, errors. - External code must not work with raw `dict[str, Any]` when a typed object can be returned instead. - Exceptions must be explicit and domain-specific; no `assert False` for flow control. - All network interaction is considered potentially unstable. @@ -34,7 +34,12 @@ Principles are listed in descending priority order when they conflict. ## Target Package Architecture -Avito API sections are organized as packages. Recommended structure: +Avito API sections are organized as packages. The target architecture for new and +materially refactored domains is documented in +`docs/site/explanations/domain-architecture-v2.md` and uses explicit domain +methods, per-domain operation specs, and dataclass-owned payload mapping. + +Recommended structure for a simple domain: ```text avito/ @@ -53,35 +58,36 @@ avito/ exceptions.py types.py pagination.py - accounts/ - __init__.py - client.py - models.py - mappers.py - ads/ - __init__.py - client.py - models.py - enums.py - mappers.py - promotion/ - __init__.py - client.py models.py - enums.py - mappers.py - messenger/ + operations.py + payload.py + fields.py + ratings/ __init__.py - client.py + domain.py + operations.py models.py - enums.py - mappers.py +``` + +Recommended structure for a large domain: + +```text +avito/ orders/ __init__.py - client.py - models.py - enums.py - mappers.py + domain.py + operations/ + __init__.py + orders.py + labels.py + delivery.py + stock.py + models/ + __init__.py + orders.py + labels.py + delivery.py + stock.py ``` Rules: @@ -90,6 +96,14 @@ Rules: - Each API section lives in its own package: `ads`, `messenger`, `orders`, `autoload`, etc. - Only modules belonging to that section are allowed inside each section package. - `avito/client.py` and `avito/__init__.py` contain only the high-level entry point and public exports. +- `domain.py` contains public `DomainObject` classes, explicit public methods, reference-ready docstrings, `@swagger_operation(...)` bindings, business validation, and construction of internal request models. +- `operations.py` or `operations/` contains internal `OperationSpec` definitions: HTTP method, path, operation name, retry policy, path rendering, request model class, response model class, and pagination/binary/multipart strategy when applicable. +- `models.py` or `models/` contains public response dataclasses, internal request/query dataclasses, colocated enum types, `from_payload()`, `to_payload()`, `to_params()`, and normalization logic. +- API domains must not introduce `client.py`, `mappers.py`, or standalone + `enums.py` unless the architecture note for the change explains why the target + layout is insufficient. +- Domain-level `client.py`, `mappers.py`, standalone `enums.py`, compatibility + adapters, and compatibility exports are forbidden for API domains. ## Public API @@ -132,7 +146,7 @@ The following are normatively part of the public contract: The following are normatively not part of the public contract: - transport request/response shapes; -- internal mapper objects; +- internal operation specs, mapping adapters, and request DTOs; - `raw_payload`, transport-layer service dataclasses, and internal DTOs; - the shape of the raw Avito API JSON response. @@ -173,10 +187,12 @@ Rules: Required separation: - `AvitoClient` — the root SDK facade. -- `SectionClient` classes — clients for specific API sections. +- `DomainObject` classes — explicit public SDK methods for business scenarios. +- `OperationSpec` and `OperationExecutor` — internal operation metadata and execution through transport. - `Transport` — HTTP request execution. - `AuthProvider` — token acquisition and refresh. -- `Mapper` — JSON to domain model conversion. +- `ApiModel` / dataclass models — JSON to domain model conversion and public serialization. +- `RequestModel` / query dataclasses — request body and query serialization. - `Settings`/`Config` — SDK configuration. Rules: @@ -184,6 +200,8 @@ Rules: - One class, one explicit area of responsibility. - Classes must not simultaneously handle HTTP, authorization, logging, and model transformation. - "God object" classes containing logic for all API sections are forbidden. +- API domains execute through `OperationSpec` and model-owned mapping only. + Domain-level section clients and standalone mapper modules are forbidden. ## Dataclasses and Models @@ -192,6 +210,10 @@ The primary model format for the SDK is `dataclass`. Rules: - Domain entities and response objects are described with `@dataclass(slots=True, frozen=True)` by default. +- Public response models inherit `ApiModel` or another project-approved subclass of `SerializableModel` and implement `from_payload(cls, payload: object)`. +- Request and query models inherit `RequestModel` or provide the same explicit `to_payload()` / `to_params()` contract. +- Response JSON mapping belongs in `ResponseModel.from_payload()`, not in ad hoc mapper functions. +- Request body and query mapping belongs in request/query dataclasses, not in public domain methods. - If a model must be mutable, that must be a conscious exception and explicitly documented. - Use concrete containers for lists: `list[Message]`, not just `list`. - Use `T | None` for optional fields, not implicit defaults. @@ -249,7 +271,7 @@ A public method must not require the user to construct internal SDK objects. Rules: - Public method arguments must be primitive types (`int`, `str`, `bool`, `float`) or well-known domain result models (not request objects). -- Request-DTOs used inside section clients must not appear in public domain method signatures. +- Request/query dataclasses used by operation execution must not appear in public domain method signatures unless they are explicitly documented public input models. - If a method requires a complex input object, it must accept its fields directly as keyword-only arguments. - All optional arguments on public methods must be keyword-only. Positional slots are reserved for the primary domain inputs of the operation. - Public methods must accept per-operation overrides for `timeout` and retry behavior as keyword-only arguments. These overrides take precedence over the client-level configuration for the single call and must not mutate client state. @@ -283,7 +305,9 @@ Fields with a fixed set of allowed values from the upstream specification must b Rules: -- Every field whose set of allowed values is defined by the API specification in `docs/avito/api/` must be represented by a public `Enum` from `avito//enums.py`. +- Every field whose set of allowed values is defined by the API specification in `docs/avito/api/` must be represented by a public `Enum` colocated with the models that use it: in `avito//models.py` for simple domains or `avito//models/*.py` for large domains. +- API-domain enum definitions belong next to their models. Domain-level + `enums.py` compatibility re-export modules are forbidden. - Enums are declared with string values matching the wire format exactly, so serialization is a direct dump without extra conversion. - Enums must be forward-compatible: an unknown upstream value must not crash mapping. Map unknown values to a designated `UNKNOWN` member or a typed fallback and log at `warning` level once per process. - Public method arguments that accept an enum may also accept the corresponding `str` literal for ergonomics, but the public method signature type annotation must be the enum type (optionally unioned with `Literal[...]`). @@ -338,12 +362,13 @@ All HTTP must go through a single transport layer. Rules: -- Direct calls to `httpx.get()`/`httpx.post()` inside section clients are forbidden. +- Direct calls to `httpx.get()`/`httpx.post()` outside the transport layer are forbidden. - 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. - Transport details must not be part of public signatures, docstrings, or serialization. +- Domain methods and operation executors call `Transport`; they do not create or own `httpx` clients. Recommendation: @@ -461,18 +486,20 @@ JSON from Avito is an external contract, not an internal application model. Rules: -- Raw JSON responses are mapped in a dedicated layer. +- Raw JSON responses are mapped at the domain model boundary through `ResponseModel.from_payload()`. - Data enrichment logic executes after transport but before returning the object to the user. - Enrichment must be deterministic and must not break the original method contract. - If enrichment is expensive or requires additional requests, it must be explicitly indicated in the API. -- Transformation of transport responses into public SDK models must be centralized. +- Transformation of transport responses into public SDK models must be centralized in the model class or a model-local helper. - The same resource must always map to the same public type, regardless of upstream payload variations within the allowed range. - Public docstrings and signatures must not require knowledge of the upstream JSON shape. +- Standalone API-domain `mappers.py` modules are forbidden. Model parsing belongs + in `Model.from_payload()` or model-local helpers. Recommendation: -- Use `mappers.py` inside each API section. -- Do not mix mapping with the HTTP call in the same method. +- Put response mapping in `ApiModel.from_payload()` and request/query serialization in `RequestModel.to_payload()` / `to_params()`. +- Do not mix mapping, HTTP execution, and public method argument validation in one method. ## Public Read Contracts @@ -634,7 +661,7 @@ Rules: - the serialization result must be JSON-compatible; - nested public models must serialize recursively; - nullable and optional fields serialize per the fixed contract rules; -- serialization must not expose transport objects, service references, or internal mapper fields; +- serialization must not expose transport objects, service references, or internal operation/mapping fields; - `to_dict()` and `model_dump()` must be explicitly declared in the class or inherited from an explicit mixin — dynamic method injection via `globals()` or `setattr` at runtime is forbidden; - the presence of serialization methods must be visible in the class definition without tracking side-effect calls during module import. @@ -659,7 +686,7 @@ Rules: - A public method docstring must describe the returned SDK model and behavior on nullable/empty cases. - A public method docstring must also document: every supported per-operation override, whether the method is idempotent, and the exception types the method raises on the most common failure modes. - Every new or changed public method that corresponds to an Avito API operation must have a docstring suitable for generated reference documentation. The docstring must identify the business action, public arguments, return model, pagination behavior if any, dry-run/idempotency behavior if any, and the common SDK exceptions. -- Docstrings must not reference the shape of the raw upstream JSON, transport classes, or internal mapper objects. +- Docstrings must not reference the shape of the raw upstream JSON, transport classes, operation specs, or internal mapping adapters. - Comments are used only where the intent cannot be expressed in code. - Comments must not duplicate what is obvious. @@ -712,6 +739,34 @@ What is not tested: Criterion: if a test cannot be broken without violating a public contract or technical decision, the test is not needed. +### Allowed Test Categories Are Closed + +The pytest suite has exactly two reasons to exist. Anything that does not fit one of these categories must be removed or moved out of `tests/`: + +1. **Functional tests** — verify runtime behavior of the SDK against a fake transport: domain methods return expected models for given payloads, error mapping, retries, auth refresh, pagination, dry-run, serialization, secret sanitization. +2. **Swagger-spec compliance tests** — verify that the SDK matches `docs/avito/api/`: every Swagger operation has exactly one binding, `SwaggerFakeTransport` invokes every binding, error responses map to the correct SDK exception. + +Swagger-spec compliance tests are mandatory. They may inspect discovered bindings, +public method signatures, operation metadata, and normalized Swagger schemas when +that inspection is needed to prove SDK-to-Swagger coverage, request/response +shape compatibility, binding uniqueness, or error-contract coverage. This is not +considered forbidden public-surface introspection or linter-of-the-linter testing; +the Swagger specification is an external API contract, and tests that prove +coverage of that contract belong in pytest. + +Anything else is forbidden in pytest: + +- **Documentation checks** — markdown placeholders, README example execution, docstring presence/format, docs-harness surface diffs. If documentation matters, lint it with `mkdocs build --strict` or a dedicated docs-only linter, not pytest. +- **Architecture / project-layout checks** — "no `client.py` in domain", "no `setattr` at runtime", "no legacy filename suffix", "no `Any` in signatures", "all public dataclasses are frozen", "module exports list X". These belong in a static linter (`scripts/lint_architecture.py`, ruff rules, mypy strict mode), not pytest. +- **Naming / style checks** — "field is not named `resource_id`", "method has no `_legacy` suffix", "domain class follows naming convention". Linter, not pytest. +- **Type-annotation checks** — these are mypy's job. +- **Inventory / report-generation tests** — "report builder includes domain table", "snapshot of 11 domains and 204 operations". Reports are CI artifacts, not behavior. +- **Linter-of-the-linter tests** — tests that exercise the SDK's own architecture/discovery linters by feeding them synthetic input. The linter is verified by running it against the real codebase in the gate, not by pytest. Swagger contract helpers are exempt only when they validate SDK-to-Swagger coverage or schema compatibility. +- **Public-surface introspection** — walking `inspect.signature` over public methods to assert annotation shapes. Mypy strict mode catches the same problems with better signal. Swagger contract tests are exempt only when signature inspection is required to invoke a discovered binding or validate `factory_args` / `method_args` against `docs/avito/api/`. +- **Reachability / import smoke tests** — "module X can be imported", "factory Y exists on `AvitoClient`". Mypy and the regular functional tests already prove this. + +If a rule is worth enforcing automatically, encode it in a linter and run the linter from `make check`. A pytest run must answer one question: does the SDK behave correctly at runtime? Any test that answers a different question is dead weight. + ### Test Architecture Tests are divided by what they verify, not by which module they cover. @@ -731,7 +786,7 @@ Tests do not make network calls. All HTTP is replaced by a controlled fake trans - allows verifying whether a call was made, how many times, with which method and body; - is used uniformly across all tests that verify the public API. -Section clients, domain objects, and transport are tested in isolation from each other. +Domain objects, operation executors, model mapping, and transport are tested in isolation from each other. ### Testing Utilities as a Public Contract @@ -759,7 +814,7 @@ def test_transport_retries_on_server_error_and_raises_after_exhaustion(): ]) # Act / Assert - with pytest.raises(ServerError): + with pytest.raises(UpstreamApiError): transport.request_json("GET", "/some/path", context=ctx) assert transport.call_count == 3 @@ -890,7 +945,7 @@ Rules: - Widespread use of `Any`. - Error handling via `assert`. - Hidden network side effects in properties and dataclasses. -- Leakage of transport-layer shapes and mapper details into public signatures and models. +- Leakage of transport-layer shapes, operation specs, and mapping details into public signatures and models. - Implicit or undocumented config resolution through the environment. - Abstract field names (`resource_id`) where a domain-specific name is known and unambiguous. - Dynamic method injection into classes via `setattr`, patching via `globals()`, or other runtime magic. diff --git a/action_plan.md b/action_plan.md deleted file mode 100644 index f4beae1..0000000 --- a/action_plan.md +++ /dev/null @@ -1,552 +0,0 @@ -# Swagger Binding Architecture Action Plan - -## Контекст для быстрого восстановления - -Репозиторий: `/Users/n.baryshnikov/Projects/avito_python_api`. - -Цель новой архитектуры: заменить старую inventory-архитектуру машинно-проверяемой canonical coverage map на базе: - -1. Swagger/OpenAPI спецификаций из `docs/avito/api/*.json`. -2. `@swagger_operation(...)` bindings на публичных SDK domain methods. -3. `swagger-lint`, который строит и валидирует карту покрытия. - -`docs/avito/inventory.md` считается артефактом старой архитектуры. Он не должен быть источником истины и не должен участвовать в новых проверках покрытия. - -Текущий Swagger corpus: - -- 23 файла в `docs/avito/api`. -- 204 операции. -- 7 deprecated операций. - -Ключевой инвариант новой архитектуры: - -```text -Swagger operation -<-> exactly one @swagger_operation SDK method --> SwaggerFakeTransport validates actual HTTP request/response --> contract tests validate all statuses and errors from Swagger -``` - -Важные локальные точки: - -- `STYLEGUIDE.md` является нормативным документом и имеет приоритет. -- Публичный фасад: `avito/client.py`, класс `AvitoClient`. -- Публичные domain methods: `avito//domain.py`. -- Section clients: `avito//client.py`. -- Старые inventory-ссылки в `CLAUDE.md`, README, docs и генераторах документации мигрированы на binding discovery. -- `Makefile` сейчас имеет `check: test typecheck lint swagger-lint build`; strict `swagger-lint` уже входит в общий gate. - -Ограничения архитектуры: - -- Декоратор не должен дублировать Swagger-контракт. -- В binding запрещены response/request schemas, statuses, content types, response models, request models, error models. -- Swagger остаётся единственным источником HTTP method/path/parameters/body/status/schema/deprecated state. -- Binding описывает только соответствие SDK method операции Swagger и способ построить SDK-вызов для contract tests. - -## Design Decisions - -1. `docs/avito/inventory.md` retired. Новая canonical coverage map строится только из Swagger specs и discovered bindings. -2. Canonical bindings ставятся на публичные domain methods в `avito//domain.py`. -3. Section clients в `avito//client.py` не являются canonical public binding target, кроме заранее описанного legacy-исключения. -4. Summary/helper methods в `AvitoClient` не получают Swagger bindings, если они не соответствуют одной конкретной upstream Swagger operation. -5. Private methods, `_require_*` helpers и internal serialization helpers не участвуют в discovery. -6. Discovery не должен создавать `AvitoClient`, читать обязательные env vars, ходить в сеть или выполнять реальные HTTP calls. -7. `avito/core/swagger.py` не должен загружать Swagger files на import time. -8. `operation_id` является дополнительной проверкой, но не primary identity. Primary identity: `spec + method + normalized_path`. -9. Allowlist для deprecated/legacy/completeness исключений по умолчанию запрещён. Если он понадобится, запись должна иметь причину и дату удаления. -10. `deprecated` в binding сверяется только с operation-level `deprecated` из Swagger operation. Deprecated schema fields, enum values и properties не влияют на operation binding. - -## Decorator Contract - -Модуль: - -```text -avito/core/swagger.py -``` - -Публичный декоратор: - -```python -@swagger_operation( - method: str, - path: str, - *, - spec: str | None = None, - operation_id: str | None = None, - factory: str | None = None, - factory_args: Mapping[str, str] | None = None, - method_args: Mapping[str, str] | None = None, - deprecated: bool = False, - legacy: bool = False, -) -``` - -Binding model: - -```python -@dataclass(frozen=True, slots=True) -class SwaggerOperationBinding: - method: str - path: str - spec: str | None - operation_id: str | None - factory: str | None - factory_args: Mapping[str, str] - method_args: Mapping[str, str] - deprecated: bool - legacy: bool -``` - -Декоратор записывает metadata в `func.__swagger_binding__`, не меняет поведение метода и не читает Swagger files на import time. - -Class-level metadata на публичных domain objects: - -```python -__swagger_domain__: str -__swagger_spec__: str -__sdk_factory__: str -__sdk_factory_args__: Mapping[str, str] -``` - -Section clients могут иметь binding metadata только как заранее описанное legacy-исключение. - -## Path Normalization - -Правила normalizing для identity и линтера: - -1. `method` приводится к uppercase. -2. `path` хранится в Swagger format: `/path/{param}`. -3. Trailing slash удаляется, кроме path `/`. -4. Path parameter syntax кроме `{name}` запрещён. -5. Path остаётся case-sensitive. -6. Primary operation key: `spec + method + normalized_path`. -7. Если `spec` не указан, auto-resolve по `method + normalized_path` разрешён только при ровно одном совпадении среди всех Swagger files. - -## Execution Modes - -`scripts/lint_swagger_bindings.py` должен поддерживать несколько режимов, чтобы внедрение можно было вести поэтапно: - -1. Default / non-strict mode: - - валидирует Swagger files; - - валидирует только уже найденные SDK bindings; - - не требует покрытия всех 204 операций; - - подходит для Этапов 1-5. -2. Strict mode: - - включает все default-проверки; - - требует, чтобы каждая Swagger operation имела ровно один SDK binding; - - включается в `make check` только после завершения доменной разметки. -3. JSON report mode: - - отдаёт machine-readable отчёт по операциям, bindings, missing/duplicate/ambiguous cases; - - используется docs/reference generator и coverage badge; - - заменяет старые inventory-derived reports. - -CLI contract: - -```bash -poetry run python scripts/lint_swagger_bindings.py -poetry run python scripts/lint_swagger_bindings.py --strict -poetry run python scripts/lint_swagger_bindings.py --json -poetry run python scripts/lint_swagger_bindings.py --json --output swagger-bindings-report.json -``` - -Exit codes: - -- `0`: ошибок нет; -- `1`: найдены validation errors; -- `2`: ошибка CLI usage, чтения specs или некорректной среды запуска. - -## JSON Report Contract - -JSON report должен быть стабильным API для docs/reference generator и badge. - -Минимальная структура: - -```json -{ - "summary": { - "specs": 23, - "operations_total": 204, - "deprecated_operations": 7, - "bound": 0, - "unbound": 204, - "duplicate": 0, - "ambiguous": 0 - }, - "operations": [], - "bindings": [], - "errors": [] -} -``` - -`operations[]` содержит `spec`, `method`, `path`, `operation_id`, `deprecated`, `status`, `binding`. - -`bindings[]` содержит `module`, `class`, `method`, `operation_key`, `factory`, `factory_args`, `method_args`. - -`errors[]` содержит `code`, `message`, `operation_key`, `sdk_method`. - -## Definition of Done - -Критерии готовности этапов: - -- Этап 0 готов, когда в документации больше нет утверждения, что inventory является canonical source of truth. -- Этап 1 готов, когда unit-тесты декоратора проходят, а `avito/core/swagger.py` не импортирует и не читает `docs/avito/api`. -- Этап 2 готов, когда registry стабильно извлекает 23 specs, 204 operations и 7 deprecated operations. -- Этап 3 готов, когда discovery на пустой/частичной разметке не требует env vars, сети и создания `AvitoClient`. -- Этап 4 готов, когда `make swagger-lint` работает в non-strict режиме и возвращает стабильные actionable error codes. -- Этап 5 готов по домену, когда все его public operation methods имеют bindings и проходят `make swagger-lint` в non-strict режиме. -- Этап 6 готов, когда strict mode подтверждает ровно один binding на каждую из 204 Swagger operations. -- Этап 7 готов, когда все `factory_args` и `method_args` проходят validation against Swagger parameters/request body. -- Этап 8 готов, когда contract tests проверяют generated SDK calls через `SwaggerFakeTransport` без реального HTTP. -- Этап 9 готов, когда `make check` включает `swagger-lint --strict` и проходит полностью. - -## Этап 0. Зафиксировать миграционное решение - -1. Обновить `CLAUDE.md`, README и docs: заменить “inventory is canonical mapping” на “Swagger bindings are canonical coverage map”. -2. Проверить, что старые `check_inventory_*` скрипты и ссылки больше не участвуют в `Makefile`, docs или CI. -3. Зафиксировать, что documentation/reference должны генерироваться из binding discovery, а не из markdown inventory. - -## Этап 1. Базовый декоратор - -1. Создать `avito/core/swagger.py`. -2. Реализовать `SwaggerOperationBinding`: - - `@dataclass(frozen=True, slots=True)`; - - `method` normalizes to uppercase; - - `factory_args` и `method_args` stored as immutable mappings; - - без загрузки Swagger на import time. -3. Реализовать `swagger_operation(...)` с публичной сигнатурой из раздела `Decorator Contract`. -4. Экспортировать публичный API из `avito/core/__init__.py`, если это соответствует локальному паттерну. -5. Добавить unit-тесты: - - metadata пишется в `func.__swagger_binding__`; - - поведение decorated method не меняется; - - mappings immutable; - - лишние/запрещённые kwargs невозможны через сигнатуру. - -## Этап 2. Swagger registry для линтера - -1. Создать импортируемый parser/helper-модуль для registry и discovery. -2. Оставить `scripts/lint_swagger_bindings.py` тонким CLI wrapper-ом. -3. Загружать все `docs/avito/api/*.json`. -4. Извлекать операции в структуру: - - `spec`; - - `method`; - - `path`; - - `operation_id`; - - `deprecated`; - - path/query/header parameters; - - request body metadata. -5. Проверять базовую валидность specs: - - JSON валиден; - - есть `paths`; - - operation keys уникальны; - - path parameters из URL совпадают с описанными параметрами. - -## Этап 3. Discovery SDK bindings - -1. В discovery-коде импортировать пакет `avito` без создания `AvitoClient`. -2. Обойти публичные domain-классы из `avito//domain.py` и найти методы с `__swagger_binding__`. -3. Для каждого binding вычислить effective metadata: - - method-level values; - - class-level `__swagger_spec__`, `__sdk_factory__`, `__sdk_factory_args__`; - - auto-resolve только если совпадение однозначно. -4. Сформировать canonical map: `Swagger operation key -> SDK method`. -5. Явно игнорировать section clients, private methods, summary methods и internal helpers. - -## Этап 3.5. Baseline coverage report - -1. Реализовать non-authoritative baseline report на базе Swagger registry и binding discovery. -2. Для каждой операции показать: - - `spec`; - - `method`; - - `path`; - - `operation_id`; - - `deprecated`; - - binding status: `bound`, `unbound`, `duplicate`, `ambiguous`. -3. Если возможно безопасно угадать SDK target, показывать guessed domain/class/method как подсказку, но не как источник истины. -4. Использовать report как рабочий инструмент разметки доменов. -5. Не возвращать `docs/avito/inventory.md` и не делать markdown inventory canonical. - -## Этап 4. MVP линтера - -1. Реализовать проверки: - - binding указывает на существующую Swagger operation; - - `spec` существует; - - `operation_id`, если указан, совпадает; - - duplicate bindings запрещены; - - `deprecated` / `legacy` согласованы со Swagger; - - factory существует на `AvitoClient`. -2. Добавить signature validation для factory и decorated SDK method: - - `factory_args` соответствуют сигнатуре factory; - - `method_args` соответствуют сигнатуре SDK method; - - required параметры покрыты mapping-ом; - - лишние mapping keys запрещены. -3. Сделать actionable ошибки с кодами вида `[SWAGGER_BINDING_NOT_FOUND]`. -4. Добавить `make swagger-lint`, запускающий non-strict mode. -5. Пока не включать strict completeness в `make check`, если binding-и ещё не расставлены на все 204 операции. - -## Этап 4.5. Deprecated / legacy policy - -1. Зафиксировать policy для 7 operation-level deprecated Swagger operations. -2. Определить, когда binding обязан иметь `legacy=True`. -3. Проверить, что deprecated public methods имеют runtime deprecation behavior, если это требуется STYLEGUIDE. -4. Запретить `legacy=True` на non-deprecated operation без явного исключения. -5. Если исключения всё же понадобятся, создать отдельный allowlist-файл с причиной и датой удаления. - -Policy: - -- Operation-level `deprecated: true` из Swagger требует `deprecated=True` и `legacy=True` в binding. -- Deprecated binding обязан указывать на public SDK method с runtime `DeprecationWarning` через `deprecated_method(...)`. -- `legacy=True` на non-deprecated Swagger operation запрещён без отдельного allowlist-исключения. -- Deprecated schema fields, properties и enum values не создают deprecated/legacy binding requirement. -- Текущие operation-level deprecated операции: `CPAАвито.json GET /cpa/v1/call/{call_id}`, `CPAАвито.json POST /cpa/v2/balanceInfo`, `CPAАвито.json POST /cpa/v2/callById`, `Автозагрузка.json GET /autoload/v1/profile`, `Автозагрузка.json POST /autoload/v1/profile`, `Автозагрузка.json GET /autoload/v2/reports/last_completed_report`, `Автозагрузка.json GET /autoload/v2/reports/{report_id}`. - -## Этап 4.75. Factory/domain mapping inventory - -1. Построить рабочую таблицу `AvitoClient factory -> domain class -> spec candidates`. -2. Проверить, что каждый factory можно introspect-ить без создания `AvitoClient`. -3. Выявить операции, которые сейчас представлены summary/helper methods и не должны получать direct binding. -4. Использовать таблицу как подготовку к доменной разметке, но не делать её source of truth. - -Результат этапа хранится как non-authoritative `factory_mapping` section в JSON report. Она помогает расставлять domain bindings, но canonical coverage по-прежнему считается только из Swagger operations и discovered `@swagger_operation` bindings. - -## Этап 5. Расстановка binding-ов по доменам - -Делать маленькими PR/commit-ами по одному домену: - -1. `accounts`, `tariffs`, `ratings` как самые маленькие. -2. `messenger`. -3. `promotion`. -4. `ads` / autoload legacy. -5. `orders` / delivery / stock. -6. `jobs`. -7. `cpa` / calltracking. -8. `autoteka`. -9. `realty`. - -Для каждого домена: - -- добавить class-level metadata; -- расставить `@swagger_operation`; -- описать `factory_args` и `method_args`; -- запускать `make test`, `make typecheck`, `make lint`, `make swagger-lint`; -- не добавлять request/response schemas в decorators. - -Для каждого домена в changelog фиксировать: - -- сколько операций стало bound; -- сколько осталось unbound; -- какие deprecated/legacy решения приняты; -- какие проверки запускались. - -## Этап 6. Strict completeness - -1. Включить проверку: каждая из 204 Swagger operations имеет ровно один binding. -2. Включить проверку: каждый binding уникален. -3. Перевести `make swagger-lint` на strict mode. -4. Сделать `make swagger-lint` частью `make check`. -5. Обновить badge/docs покрытия: coverage теперь считается из Swagger registry + binding discovery. - -## Этап 6.5. Documentation migration - -1. Перевести generated reference/coverage docs на JSON report или импортируемый discovery API. -2. Удалить или переписать оставшиеся inventory-derived docs paths. -3. Обновить README badge/description: coverage считается из Swagger bindings. -4. Проверить, что `docs/site/reference/coverage.md` и related pages не называют inventory источником истины. - -## Этап 7. Path expression validation - -1. Проверять `path.` против path params. -2. Проверять `query.` против query params. -3. Проверять `header.` против header params. -4. Проверять `body` и `body.` против request body. -5. Ввести test constants registry для `constant.`. -6. Запретить любые expressions вне whitelist. - -## Этап 8. Contract tests - -1. Реализовать `SwaggerFakeTransport`. -2. На основе binding-а строить SDK вызов из generated request data. -3. Request-contract tests: проверять, что SDK делает HTTP request, соответствующий Swagger: - - method; - - path; - - path/query/header params; - - body shape; - - content type. -4. Response-contract tests: проверять happy-path response mapping в typed SDK models. -5. Error-contract tests: проверять statuses и exception mapping из Swagger. -6. Отдельно проверить deprecated/legacy операции. -7. Начать с read-only операций, потом расширить на write-операции и idempotency. - -## Этап 9. Финальный gate - -1. Запустить: - - `make test`; - - `make typecheck`; - - `make lint`; - - `make swagger-lint`; - - `make build`. -2. Затем `make check`, где `swagger-lint` уже должен быть включён. -3. Проверить, что старый inventory нигде не упоминается как источник истины. - -## Этап 10. Устранение выявленных несоответствий после выполнения плана - -Цель: отдельным новым этапом закрыть несоответствия, найденные после выполнения Этапов 0-9, не переписывая историю уже выполненных пунктов. - -Не входит в этот этап: - -- дефект публичного Swagger corpus с несовпадением `{userId}` / `{itemId}` и `pathUserId` / `pathItemId`; -- patch/override pipeline для upstream specs. - -### 10.1. Запрет нескольких bindings на один SDK method - -Требование: несколько Swagger bindings на один SDK method запрещены. Каждая Swagger operation должна иметь собственный discovered SDK method target. - -1. Найти все SDK methods, на которых discovery видит больше одного Swagger binding. -2. Для каждого случая выбрать явное разделение: - - отдельные public SDK methods, если операции являются разными пользовательскими действиями; - - отдельные documented wrappers, если один сценарий раньше скрывал несколько upstream modes; - - отдельные low-level auth SDK targets для token operations, если они остаются non-domain binding exception. -3. Удалить поддержку stacked `@swagger_operation(...)` из декоратора: - - не накапливать `func.__swagger_bindings__`; - - повторная установка binding на метод должна быть ошибкой или должна явно запрещаться тестом. -4. Обновить discovery: - - читать только `func.__swagger_binding__`; - - считать `__swagger_bindings__` или несколько bindings на одном method ошибкой совместимости. -5. Обновить linter: - - добавить error code `SWAGGER_BINDING_METHOD_MULTIPLE`; - - падать, если один `sdk_method` связан больше чем с одной operation; - - не вводить allowlist для multi-binding methods. -6. Обновить JSON report: - - оставить `bindings[]` как плоский список one binding per sdk_method; - - добавить ошибку в `errors[]`, если обнаружено legacy `__swagger_bindings__`. -7. Обновить docs: - - `docs/site/explanations/swagger-binding-subsystem.md`; - - `STYLEGUIDE.md`; - - `CLAUDE.md`; - - убрать формулировки, допускающие multi-operation SDK methods. -8. Добавить тесты: - - декоратор запрещает stacked bindings; - - discovery/linter ловят legacy `__swagger_bindings__`; - - strict report остаётся `204/204 bound`, `0 duplicate`, `0 ambiguous`, `0 errors`. - -### 10.2. Schema-aware validation для `body.` - -Требование: `body.` должен проверяться против request body schema/properties, а не только против наличия `requestBody`. - -1. Расширить `SwaggerRequestBody` в `avito/core/swagger_registry.py`: - - хранить `content_types`; - - хранить top-level body field names/properties; - - хранить флаг, что schema была успешно извлечена. -2. Добавить schema resolver для локальных `$ref`: - - `#/components/schemas/`; - - object schemas с `properties`; - - `allOf`/`oneOf`/`anyOf` только если можно безопасно извлечь top-level properties; иначе фиксировать unsupported schema state. -3. В `swagger_linter.py` изменить проверку `body.`: - - если `requestBody` отсутствует — текущая ошибка `SWAGGER_BINDING_BODY_MISSING`; - - если schema/properties доступны и поля нет — новая ошибка `SWAGGER_BINDING_BODY_FIELD_NOT_FOUND`; - - если schema не поддержана для field-level validation — новая actionable ошибка `SWAGGER_BINDING_BODY_SCHEMA_UNSUPPORTED`. -4. Добавить tests для registry: - - inline object schema; - - `$ref` schema; - - missing properties; - - unsupported schema shape. -5. Добавить tests для linter: - - valid `body.`; - - invalid `body.missing`; - - `body.` при unsupported schema; - - `body` остаётся валидным при любом request body. -6. Обновить JSON report/errors contract, если добавляются новые error codes. -7. Обновить `docs/site/explanations/swagger-binding-subsystem.md`, убрав оговорку, что field-level validation ещё не реализована. - -### 10.3. Усиление contract tests до полного binding/status coverage - -Требование: contract tests должны параметризованно покрывать все discovered bindings и все Swagger error status contracts, а не только representative samples/status categories. - -1. Добавить parametrized request-contract test по всем discovered bindings: - - загрузить registry; - - загрузить discovery; - - для каждого binding зарегистрировать success response; - - вызвать SDK method через `SwaggerFakeTransport.invoke_binding`; - - проверить, что request matched Swagger method/path и прошёл validation path/query/header/body/content-type. -2. Добавить deterministic payload generator: - - использовать Swagger response schema, где она доступна; - - использовать controlled payload registry для операций, где mapper требует доменно-специфичную форму; - - запрещать неописанные silent fallbacks, которые маскируют отсутствие payload contract. -3. Добавить parametrized error-contract test по всем Swagger error responses: - - для каждой operation и каждого numeric error status зарегистрировать `error_payload(status)`; - - вызвать соответствующий binding; - - проверить exception type по transport error mapping; - - проверить, что message/metadata не нарушают публичный error contract. -4. Добавить coverage assertions: - - количество request-contract cases равно количеству discovered bindings; - - количество error-contract cases равно количеству numeric Swagger error responses; - - deprecated operations входят в общий набор и дополнительно проверяют `DeprecationWarning`. -5. Если generated call невозможен для отдельной операции, тест должен падать. Allowlist для contract gaps не вводить без отдельного решения. -6. Обновить `SwaggerFakeTransport`, если нужно: - - добавить schema-aware success payload generation helpers; - - расширить test constants registry; - - улучшить diagnostics при невозможности построить вызов. -7. Обновить docs/testing notes: - - `docs/site/explanations/swagger-binding-subsystem.md`; - - `docs/site/explanations/testing-strategy.md`; - - `STYLEGUIDE.md`, если меняется обязательный verification set. - -### 10.4. Verification - -```bash -pytest tests/core/test_swagger.py tests/core/test_swagger_discovery.py tests/core/test_swagger_linter.py tests/core/test_swagger_report.py -pytest tests/core/test_swagger_registry.py tests/contracts/test_swagger_contracts.py -make swagger-lint -mypy avito -ruff check avito tests/core tests/contracts/test_swagger_contracts.py -make docs-strict -make check -``` - -## Критичный порядок - -Не начинать с `SwaggerFakeTransport`. Сначала нужна стабильная карта `Swagger operation -> SDK method`. - -Самый безопасный MVP: - -1. Декоратор. -2. Swagger registry. -3. Binding discovery. -4. Baseline coverage report. -5. Линтер в non-strict режиме. -6. Deprecated/legacy policy. -7. Factory/domain mapping inventory. -8. Доменные binding-и. -9. Strict completeness. -10. Documentation migration. -11. Contract tests. - -## Changelog - -Записи добавляются при выполнении или изменении плана. - -Формат: - -| Date | Change | Status | Verification | -|---|---|---|---| -| 2026-04-29 | Создан `action_plan.md` с контекстом, этапами реализации и changelog. | Done | Manual review | -| 2026-04-29 | Добавлены design decisions, execution modes, definition of done, baseline report, deprecated/legacy policy и documentation migration. | Done | Manual review | -| 2026-04-29 | Удалены ссылки на внешний контекст, добавлены decorator contract, path normalization, CLI/JSON report contract, factory inventory и разбиение contract tests. | Done | Manual review | -| 2026-04-29 | Выполнен Этап 0: README, CLAUDE/AGENTS, docs и PR template переведены на Swagger bindings; inventory checks удалены из docs CI. | Done | `rg` по inventory/check_inventory/canonical source; manual review | -| 2026-04-29 | Выполнен Этап 1: добавлен `avito/core/swagger.py`, экспорт core API и unit-тесты декоратора. | Done | `pytest tests/core/test_swagger.py`; `pytest`; `mypy avito`; `ruff check` | -| 2026-04-29 | Выполнен Этап 2: добавлен Swagger registry/parser, тонкий `lint_swagger_bindings.py` wrapper и тесты на corpus 23/204/7. | Done | `pytest tests/core/test_swagger_registry.py`; `python scripts/lint_swagger_bindings.py`; `mypy avito`; `ruff check` | -| 2026-04-29 | Выполнен Этап 3: добавлен discovery публичных domain bindings с class-level defaults, auto-resolve spec и canonical map. | Done | `pytest tests/core/test_swagger_discovery.py`; `pytest`; `mypy avito`; `ruff check` | -| 2026-04-29 | Выполнен Этап 3.5: добавлен baseline JSON report по Swagger registry + binding discovery, статусы `bound`/`unbound`/`duplicate`/`ambiguous` и JSON-режим CLI. | Done | `pytest tests/core/test_swagger.py tests/core/test_swagger_registry.py tests/core/test_swagger_discovery.py tests/core/test_swagger_report.py`; `ruff check avito/core/swagger_report.py scripts/lint_swagger_bindings.py tests/core/test_swagger_report.py`; `mypy avito`; `python scripts/lint_swagger_bindings.py --json --output /tmp/swagger-bindings-report.json` | -| 2026-04-29 | Выполнен Этап 4: добавлен MVP Swagger binding linter, validation ошибок binding/spec/operation_id/duplicate/deprecated/legacy/factory/signature, `make swagger-lint` и исправлены 2 path parameter mismatch в локальном Swagger corpus. | Done | `pytest tests/core/test_swagger.py tests/core/test_swagger_registry.py tests/core/test_swagger_discovery.py tests/core/test_swagger_report.py tests/core/test_swagger_linter.py`; `make swagger-lint`; `pytest`; `mypy avito`; `ruff check .`; `python scripts/lint_swagger_bindings.py --json --output /tmp/swagger-bindings-report-stage4.json` | -| 2026-04-29 | Выполнен Этап 4.5: зафиксирована deprecated/legacy policy для 7 operation-level deprecated operations, runtime deprecation metadata добавлена в `deprecated_method`, linter требует `legacy=True` и runtime warning для deprecated bindings. | Done | `pytest tests/core/test_swagger_registry.py tests/core/test_swagger_linter.py`; `make swagger-lint`; `pytest`; `mypy avito`; `ruff check .` | -| 2026-04-29 | Выполнен Этап 4.75: добавлен non-authoritative factory/domain mapping report для `AvitoClient factory -> domain class -> spec candidates`, introspection без создания клиента и список summary/helper methods без direct binding. | Done | `pytest tests/core/test_swagger_factory_map.py tests/core/test_swagger_report.py tests/core/test_swagger_linter.py`; `python scripts/lint_swagger_bindings.py --json --output /tmp/swagger-bindings-report-stage475.json`; `make swagger-lint`; `pytest`; `mypy avito`; `ruff check .` | -| 2026-04-29 | Выполнен Этап 5: расставлены Swagger bindings на все публичные domain operation methods: accounts 8, tariffs 1, ratings 4, messenger 18, promotion 24, ads/autoload 28, orders/delivery/stock 44, jobs 22, cpa/calltracking 13, autoteka 26, realty 7. Coverage report: bound 195, unbound 9, duplicate 0, ambiguous 0. Unbound остались только token operations и альтернативные ветки существующих мульти-режимных методов (`version=1`, `ids`, `extended=True`). | Done | `make swagger-lint`; `poetry run python scripts/lint_swagger_bindings.py --json --output /tmp/swagger-stage5-after.json`; AST-check public domain methods without bindings; `pytest`; `mypy avito`; `ruff check .` | -| 2026-04-29 | Выполнен Этап 6: strict completeness включён в CLI и `make check`; временные multi-binding targets для альтернативных upstream modes и OAuth token operations закрыли coverage до 204/204 bound, 0 unbound, 0 duplicate, 0 ambiguous. | Done | `poetry run python scripts/lint_swagger_bindings.py --json --strict --output /tmp/swagger-stage6.json`; `make swagger-lint`; `make check` | -| 2026-04-29 | Выполнен Этап 6.5: generated reference/coverage переведены на Swagger binding report, docs CI/docs-report используют strict report, оставшиеся ссылки на удалённые docs-report scripts убраны. | Done | `make docs-strict`; `make docs-report`; `rg` по inventory/check_inventory/удалённым docs scripts; manual review generated `site/reference/coverage` и `site/reference/operations` | -| 2026-04-29 | Выполнен Этап 7: linter валидирует `path.`, `query.`, `header.`, `body`/`body.` и `constant.` expressions; class-level factory defaults фильтруются по Swagger operation; исправлены bindings для autoload query/upload и Autoteka token. | Done | `pytest tests/core/test_swagger*.py`; `poetry run python scripts/lint_swagger_bindings.py --json --strict --output /tmp/swagger-stage7.json`; `make swagger-lint`; `mypy avito`; `ruff check avito tests/core/test_swagger_linter.py` | -| 2026-04-29 | Выполнен Этап 8: добавлен `SwaggerFakeTransport`, generated SDK call invocation по discovered bindings, request validation для method/path/path-query-header params/body/content-type, response happy-path mapping, error status mapping для всех Swagger error status categories и deprecated/legacy runtime warning contract. | Done | `poetry run pytest tests/contracts/test_swagger_contracts.py tests/core/test_swagger_registry.py tests/core/test_swagger_linter.py tests/contracts/test_public_surface.py`; `poetry run python scripts/lint_swagger_bindings.py --strict`; `poetry run mypy avito`; `poetry run ruff check avito tests/contracts/test_swagger_contracts.py tests/contracts/test_public_surface.py tests/core/test_swagger_registry.py`; `poetry run pytest`; `make check` | -| 2026-04-29 | Выполнен Этап 9: финальный gate пройден отдельными командами и через `make check`; проверено, что старый markdown inventory не упоминается как источник истины. | Done | `make test`; `make typecheck`; `make lint`; `make swagger-lint`; `make build`; `make check`; `rg` по `inventory`/`check_inventory`/`source of truth` | -| 2026-04-29 | Добавлен новый Этап 10 для устранения несоответствий после выполнения плана: запрет нескольких bindings на один SDK method, schema-aware validation для `body.`, усиление contract tests до полного binding/status coverage. Upstream Swagger mismatch не входит в этап и остаётся отдельной задачей. | Planned | Manual review | -| 2026-04-29 | Выполнен Этап 10.1: multi-binding SDK methods разделены на отдельные discovered targets, stacked decorators запрещены, discovery/linter ловят legacy `__swagger_bindings__`, docs больше не допускают multi-operation SDK methods. | Done | `poetry run pytest tests/core/test_swagger.py tests/core/test_swagger_discovery.py tests/core/test_swagger_linter.py tests/core/test_swagger_report.py`; `poetry run python scripts/lint_swagger_bindings.py --json --strict --output /tmp/swagger-10-1.json`; `jq` check for duplicate `sdk_method`; `make swagger-lint`; `poetry run mypy avito`; `poetry run ruff check avito tests/core docs/site/assets/_gen_reference.py` | -| 2026-04-29 | Выполнен Этап 10.2: `SwaggerRequestBody` хранит content types, top-level schema fields и schema extraction flag; registry извлекает inline/`$ref`/composed object properties; linter проверяет `body.` и выдаёт `SWAGGER_BINDING_BODY_FIELD_NOT_FOUND` / `SWAGGER_BINDING_BODY_SCHEMA_UNSUPPORTED`; bindings приведены к schema-aware expressions. | Done | `poetry run pytest tests/core/test_swagger_registry.py tests/core/test_swagger_linter.py`; `poetry run python scripts/lint_swagger_bindings.py --json --strict --output /tmp/swagger-10-2.json`; `make swagger-lint`; `poetry run mypy avito`; `poetry run ruff check avito tests/core docs/site/assets/_gen_reference.py`; `make check` | -| 2026-04-29 | Выполнен Этап 10.3: contract tests усилены до полного request coverage по 204 discovered bindings и полного error coverage по 639 numeric Swagger error responses; `SwaggerFakeTransport` получил generated success invocation для auth/domain bindings, controlled success payload registry и дополнительные SDK argument builders; исправлены выявленные Swagger request drift в query/header параметрах. | Done | `poetry run pytest tests/contracts/test_swagger_contracts.py`; full verification по 10.4 | -| 2026-04-29 | Выполнен Этап 10.4: пройден полный verification set после усиления contract tests; strict binding report подтверждает 204/204 bound, 0 unbound, 0 duplicate, 0 ambiguous, 0 validation errors. | Done | `poetry run pytest tests/core/test_swagger.py tests/core/test_swagger_discovery.py tests/core/test_swagger_linter.py tests/core/test_swagger_report.py`; `poetry run pytest tests/core/test_swagger_registry.py tests/contracts/test_swagger_contracts.py`; `make swagger-lint`; `poetry run mypy avito`; `poetry run ruff check avito tests/core tests/contracts/test_swagger_contracts.py`; `make docs-strict`; `make check` | diff --git a/avito/__init__.py b/avito/__init__.py index 1d400de..d54fa85 100644 --- a/avito/__init__.py +++ b/avito/__init__.py @@ -7,6 +7,7 @@ AuthenticationError, AuthorizationError, AvitoError, + ClientClosedError, ConfigurationError, ConflictError, RateLimitError, @@ -41,6 +42,7 @@ "CapabilityDiscoveryResult", "CapabilityInfo", "ChatSummary", + "ClientClosedError", "ConfigurationError", "ConflictError", "ListingHealthItem", diff --git a/avito/_env.py b/avito/_env.py index dc05e37..7031cb8 100644 --- a/avito/_env.py +++ b/avito/_env.py @@ -7,8 +7,6 @@ from json import JSONDecodeError, loads from pathlib import Path -from avito.core.exceptions import ConfigurationError - def read_dotenv(env_file: str | Path | None) -> dict[str, str]: """Читает простой `.env` файл без побочных эффектов.""" @@ -69,6 +67,8 @@ def _first_present(source: Mapping[str, str], aliases: tuple[str, ...]) -> str | def parse_env_int(value: str, *, field_name: str) -> int: """Преобразует env-значение в `int` с typed-ошибкой.""" + from avito.core.exceptions import ConfigurationError + try: return int(value) except ValueError as exc: @@ -80,6 +80,8 @@ def parse_env_int(value: str, *, field_name: str) -> int: def parse_env_float(value: str, *, field_name: str) -> float: """Преобразует env-значение в `float` с typed-ошибкой.""" + from avito.core.exceptions import ConfigurationError + try: return float(value) except ValueError as exc: @@ -91,6 +93,8 @@ def parse_env_float(value: str, *, field_name: str) -> float: def parse_env_bool(value: str, *, field_name: str) -> bool: """Преобразует env-значение в `bool` с typed-ошибкой.""" + from avito.core.exceptions import ConfigurationError + normalized = value.strip().lower() if normalized in {"1", "true", "yes", "on"}: return True @@ -104,6 +108,8 @@ def parse_env_bool(value: str, *, field_name: str) -> bool: def parse_env_str_tuple(value: str, *, field_name: str) -> tuple[str, ...]: """Преобразует env-значение в кортеж строк.""" + from avito.core.exceptions import ConfigurationError + stripped = value.strip() if not stripped: return () diff --git a/avito/accounts/__init__.py b/avito/accounts/__init__.py index 8922339..d4fd45f 100644 --- a/avito/accounts/__init__.py +++ b/avito/accounts/__init__.py @@ -1,23 +1,21 @@ """Пакет accounts.""" from avito.accounts.domain import Account, AccountHierarchy -from avito.accounts.enums import ( - AccountHierarchyRole, - EmployeeItemStatus, - OperationStatus, - OperationType, -) from avito.accounts.models import ( AccountActionResult, AccountBalance, + AccountHierarchyRole, AccountProfile, AhUserStatus, CompanyPhone, CompanyPhonesResult, Employee, EmployeeItem, + EmployeeItemStatus, EmployeesResult, OperationRecord, + OperationStatus, + OperationType, ) __all__ = ( diff --git a/avito/accounts/client.py b/avito/accounts/client.py deleted file mode 100644 index ff26862..0000000 --- a/avito/accounts/client.py +++ /dev/null @@ -1,207 +0,0 @@ -"""Внутренние section clients для раздела accounts.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from avito.accounts.mappers import ( - map_account_balance, - map_account_profile, - map_action_result, - map_ah_user_status, - map_company_phones, - map_employee_items, - map_employees, - map_operations_history, -) -from avito.accounts.models import ( - AccountActionResult, - AccountBalance, - AccountProfile, - AhUserStatus, - CompanyPhonesResult, - EmployeeItem, - EmployeeItemLinkRequest, - EmployeeItemsRequest, - EmployeesResult, - OperationRecord, - OperationsHistoryRequest, -) -from avito.core import JsonPage, PaginatedList, Paginator, RequestContext, Transport -from avito.core.mapping import request_public_model - - -@dataclass(slots=True, frozen=True) -class AccountsClient: - """Выполняет HTTP-операции по разделу информации о пользователе.""" - - transport: Transport - - def get_self(self) -> AccountProfile: - """Получает профиль авторизованного пользователя.""" - - return request_public_model( - self.transport, - "GET", - "/core/v1/accounts/self", - context=RequestContext("accounts.get_self"), - mapper=map_account_profile, - ) - - def get_balance(self, *, user_id: int) -> AccountBalance: - """Получает баланс аккаунта.""" - - return request_public_model( - self.transport, - "GET", - f"/core/v1/accounts/{user_id}/balance/", - context=RequestContext("accounts.get_balance"), - mapper=map_account_balance, - ) - - 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 = 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=date_from, - date_to=date_to, - limit=page_size, - offset=current_offset, - ) - result = request_public_model( - self.transport, - "POST", - "/core/v1/accounts/operations_history/", - context=RequestContext("accounts.get_operations_history", allow_retry=True), - mapper=map_operations_history, - json_body=paged_request.to_payload(), - ) - return JsonPage( - items=result.operations, - total=result.total, - page=current_page, - per_page=page_size, - ) - - return Paginator(fetch_page).as_list(first_page=fetch_page(1, None)) - - -@dataclass(slots=True, frozen=True) -class HierarchyClient: - """Выполняет HTTP-операции по иерархии аккаунтов.""" - - transport: Transport - - def get_status(self) -> AhUserStatus: - """Получает статус пользователя в ИА.""" - - return request_public_model( - self.transport, - "GET", - "/checkAhUserV1", - context=RequestContext("accounts.hierarchy.get_status"), - mapper=map_ah_user_status, - ) - - def list_employees(self) -> EmployeesResult: - """Получает список сотрудников иерархии.""" - - return request_public_model( - self.transport, - "GET", - "/getEmployeesV1", - context=RequestContext("accounts.hierarchy.list_employees"), - mapper=map_employees, - ) - - def list_company_phones(self) -> CompanyPhonesResult: - """Получает список телефонов компании.""" - - return request_public_model( - self.transport, - "GET", - "/listCompanyPhonesV1", - context=RequestContext("accounts.hierarchy.list_company_phones"), - mapper=map_company_phones, - ) - - 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=idempotency_key is not None, - ), - mapper=map_action_result, - 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, - *, - employee_id: int, - limit: int | None = None, - offset: int | None = None, - ) -> PaginatedList[EmployeeItem]: - """Получает список объявлений по сотруднику.""" - - page_size = limit or 25 - - def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[EmployeeItem]: - current_page = page or 1 - current_offset = (offset or 0) + (current_page - 1) * page_size - paged_request = EmployeeItemsRequest( - employee_id=employee_id, - limit=page_size, - offset=current_offset, - ) - result = request_public_model( - self.transport, - "POST", - "/listItemsByEmployeeIdV1", - context=RequestContext( - "accounts.hierarchy.list_items_by_employee", allow_retry=True - ), - mapper=map_employee_items, - json_body=paged_request.to_payload(), - ) - return JsonPage( - items=result.items, - total=result.total, - page=current_page, - per_page=page_size, - ) - - return Paginator(fetch_page).as_list(first_page=fetch_page(1, None)) - - -__all__ = ("AccountsClient", "HierarchyClient") diff --git a/avito/accounts/domain.py b/avito/accounts/domain.py index e07f755..305e4a2 100644 --- a/avito/accounts/domain.py +++ b/avito/accounts/domain.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from datetime import datetime -from avito.accounts.client import AccountsClient, HierarchyClient from avito.accounts.models import ( AccountActionResult, AccountBalance, @@ -14,16 +13,36 @@ AhUserStatus, CompanyPhonesResult, EmployeeItem, + EmployeeItemLinkRequest, + EmployeeItemsRequest, EmployeesResult, OperationRecord, + OperationsHistoryRequest, +) +from avito.accounts.operations import ( + GET_AH_USER_STATUS, + GET_BALANCE, + GET_OPERATIONS_HISTORY, + GET_SELF, + LINK_ITEMS, + LIST_COMPANY_PHONES, + LIST_EMPLOYEES, + LIST_ITEMS_BY_EMPLOYEE, +) +from avito.core import ( + ApiTimeouts, + JsonPage, + PaginatedList, + Paginator, + RetryOverride, + ValidationError, ) -from avito.core import PaginatedList from avito.core.domain import DomainObject from avito.core.swagger import swagger_operation -def _serialize_datetime(value: datetime | None) -> str | None: - return value.isoformat() if value is not None else None +def _serialize_datetime(value: datetime) -> str: + return value.isoformat() @dataclass(slots=True, frozen=True) @@ -42,13 +61,26 @@ class Account(DomainObject): spec="Информацияопользователе.json", operation_id="getUserInfoSelf", ) - def get_self(self) -> AccountProfile: + def get_self( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AccountProfile: """Получает профиль авторизованного пользователя. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AccountProfile` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return AccountsClient(self.transport).get_self() + return self._execute(GET_SELF, timeout=timeout, retry=retry) @swagger_operation( "GET", @@ -56,42 +88,97 @@ def get_self(self) -> AccountProfile: spec="Информацияопользователе.json", operation_id="getUserBalance", ) - def get_balance(self, user_id: int | None = None) -> AccountBalance: - """Получает баланс пользователя. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + def get_balance( + self, + *, + user_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AccountBalance: + """Получает баланс пользователя по явно заданному или настроенному `user_id`. + + Аргументы: + user_id: идентификатор пользователя; если не передан, используется `user_id` фабрики, `AVITO_USER_ID` или `get_self()`. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AccountBalance` с реальным, бонусным и суммарным балансом. + + Поведение: + `user_id` является keyword-only, чтобы вызов явно показывал источник аккаунта. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - resolved_user_id = self._resolve_user_id(user_id or self.user_id) - return AccountsClient(self.transport).get_balance(user_id=resolved_user_id) + resolved_user_id = self._resolve_account_user_id(user_id) + return self._execute( + GET_BALANCE, path_params={"user_id": resolved_user_id}, timeout=timeout, retry=retry + ) @swagger_operation( "POST", "/core/v1/accounts/operations_history", spec="Информацияопользователе.json", operation_id="postOperationsHistory", + method_args={"date_from": "body.dateTimeFrom", "date_to": "body.dateTimeTo"}, ) def get_operations_history( self, *, - date_from: datetime | None = None, - date_to: datetime | None = None, - limit: int | None = None, - offset: int | None = None, + date_from: datetime, + date_to: datetime, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> PaginatedList[OperationRecord]: - """Получает историю операций пользователя. + """Возвращает историю операций аккаунта за выбранный период. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Аргументы: + date_from: задает начальную дату периода. + date_to: задает конечную дату периода. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Возвращает: + Ленивый `PaginatedList[OperationRecord]`; первая страница загружается при создании, следующие страницы - при итерации. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return AccountsClient(self.transport).get_operations_history( - date_from=_serialize_datetime(date_from), - date_to=_serialize_datetime(date_to), - limit=limit, - offset=offset, - ) + def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[OperationRecord]: + result = self._execute( + GET_OPERATIONS_HISTORY, + request=OperationsHistoryRequest( + date_from=_serialize_datetime(date_from), + date_to=_serialize_datetime(date_to), + ), + timeout=timeout, + retry=retry, + ) + return JsonPage( + items=result.operations, + total=result.total, + ) + + return Paginator(fetch_page).as_list(first_page=fetch_page(1, None)) + + def _resolve_account_user_id(self, user_id: int | None) -> int: + if user_id is not None or self.user_id is not None: + return self._resolve_user_id(user_id or self.user_id) + profile = self.get_self() + if profile.user_id is None: + raise ValidationError( + "Для операции требуется `user_id`: передайте его в фабрику клиента, " + "в метод операции или задайте `AVITO_USER_ID`." + ) + return profile.user_id @dataclass(slots=True, frozen=True) @@ -110,13 +197,26 @@ class AccountHierarchy(DomainObject): spec="ИерархияАккаунтов.json", operation_id="checkAhUserV1", ) - def get_status(self) -> AhUserStatus: + def get_status( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AhUserStatus: """Получает статус пользователя в ИА. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AhUserStatus` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return HierarchyClient(self.transport).get_status() + return self._execute(GET_AH_USER_STATUS, timeout=timeout, retry=retry) @swagger_operation( "GET", @@ -124,15 +224,26 @@ def get_status(self) -> AhUserStatus: spec="ИерархияАккаунтов.json", operation_id="getEmployeesV1", ) - def list_employees(self) -> EmployeesResult: - """Получает список сотрудников иерархии. + def list_employees( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> EmployeesResult: + """Возвращает сотрудников компании в иерархии аккаунта. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Возвращает: + `EmployeesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return HierarchyClient(self.transport).list_employees() + return self._execute(LIST_EMPLOYEES, timeout=timeout, retry=retry) @swagger_operation( "GET", @@ -140,15 +251,26 @@ def list_employees(self) -> EmployeesResult: spec="ИерархияАккаунтов.json", operation_id="listCompanyPhonesV1", ) - def list_company_phones(self) -> CompanyPhonesResult: - """Получает список телефонов компании. + def list_company_phones( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> CompanyPhonesResult: + """Возвращает телефоны компании из иерархии аккаунта. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `CompanyPhonesResult` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return HierarchyClient(self.transport).list_company_phones() + return self._execute(LIST_COMPANY_PHONES, timeout=timeout, retry=retry) @swagger_operation( "POST", @@ -164,19 +286,40 @@ def link_items( item_ids: Sequence[int], source_employee_id: int | None = None, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> AccountActionResult: """Прикрепляет объявления к сотруднику. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + employee_id: идентификатор сотрудника, к которому прикрепляются объявления. + item_ids: список идентификаторов объявлений. + source_employee_id: идентификатор сотрудника-источника, если объявления переносятся между сотрудниками. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AccountActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return HierarchyClient(self.transport).link_items( - employee_id=employee_id, - item_ids=list(item_ids), - source_employee_id=source_employee_id, + return self._execute( + LINK_ITEMS, + request=EmployeeItemLinkRequest( + employee_id=employee_id, + item_ids=list(item_ids), + source_employee_id=source_employee_id, + ), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -184,27 +327,60 @@ def link_items( "/listItemsByEmployeeIdV1", spec="ИерархияАккаунтов.json", operation_id="listItemsByEmployeeIdV1", - method_args={"employee_id": "body.employee_id"}, + method_args={ + "employee_id": "body.employee_id", + "category_id": "body.category_id", + }, ) def list_items_by_employee( self, *, employee_id: int, - limit: int | None = None, - offset: int | None = None, + category_id: int, + last_item_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> PaginatedList[EmployeeItem]: - """Получает список объявлений сотрудника. + """Возвращает объявления, закрепленные за сотрудником компании. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Аргументы: + employee_id: идентифицирует сотрудника аккаунта. + category_id: ограничивает объявления категорией из справочника Авито. + last_item_id: задает курсор для продолжения выборки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Возвращает: + Ленивый `PaginatedList[EmployeeItem]`; первая страница загружается при создании, следующие страницы - при итерации. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return HierarchyClient(self.transport).list_items_by_employee( - employee_id=employee_id, - limit=limit, - offset=offset, - ) + def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[EmployeeItem]: + current_page = page or 1 + result = self._execute( + LIST_ITEMS_BY_EMPLOYEE, + request=EmployeeItemsRequest( + employee_id=employee_id, + category_id=category_id, + last_item_id=last_item_id, + ), + timeout=timeout, + retry=retry, + ) + return JsonPage( + items=result.items, + total=result.total, + page=current_page, + per_page=len(result.items), + ) + + return Paginator(fetch_page).as_list(first_page=fetch_page(1, None)) __all__ = ("Account", "AccountHierarchy") diff --git a/avito/accounts/enums.py b/avito/accounts/enums.py deleted file mode 100644 index 89c2c9e..0000000 --- a/avito/accounts/enums.py +++ /dev/null @@ -1,41 +0,0 @@ -"""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 deleted file mode 100644 index b802345..0000000 --- a/avito/accounts/mappers.py +++ /dev/null @@ -1,246 +0,0 @@ -"""Мапперы JSON -> dataclass для пакета accounts.""" - -from __future__ import annotations - -from collections.abc import Mapping -from datetime import datetime -from typing import cast - -from avito.accounts.enums import ( - AccountHierarchyRole, - EmployeeItemStatus, - OperationStatus, - OperationType, -) -from avito.accounts.models import ( - AccountActionResult, - AccountBalance, - AccountProfile, - AhUserStatus, - CompanyPhone, - CompanyPhonesResult, - Employee, - EmployeeItem, - EmployeeItemsResult, - EmployeesResult, - OperationRecord, - OperationsHistoryResult, -) -from avito.core.enums import map_enum_or_unknown -from avito.core.exceptions import ResponseMappingError - -Payload = Mapping[str, object] - - -def _expect_mapping(payload: object) -> Payload: - if not isinstance(payload, Mapping): - raise ResponseMappingError("Ожидался JSON-объект.", payload=payload) - return cast(Payload, payload) - - -def _as_list(payload: Payload, *keys: str) -> list[Payload]: - for key in keys: - value = payload.get(key) - if isinstance(value, list): - return [item for item in value if isinstance(item, Mapping)] - return [] - - -def _as_str(payload: Payload, *keys: str) -> str | None: - for key in keys: - value = payload.get(key) - if isinstance(value, str): - return value - return None - - -def _as_datetime(payload: Payload, *keys: str) -> datetime | None: - for key in keys: - value = payload.get(key) - if isinstance(value, str): - normalized = value.replace("Z", "+00:00") - try: - return datetime.fromisoformat(normalized) - except ValueError: - continue - return None - - -def _as_int(payload: Payload, *keys: str) -> int | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - continue - if isinstance(value, int): - return value - return None - - -def _as_float(payload: Payload, *keys: str) -> float | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - continue - if isinstance(value, (int, float)): - return float(value) - return None - - -def _as_bool(payload: Payload, *keys: str) -> bool | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - return value - return None - - -def map_account_profile(payload: object) -> AccountProfile: - """Преобразует профиль аккаунта в dataclass.""" - - data = _expect_mapping(payload) - return AccountProfile( - user_id=_as_int(data, "id", "user_id"), - name=_as_str(data, "name", "title"), - email=_as_str(data, "email"), - phone=_as_str(data, "phone"), - ) - - -def map_account_balance(payload: object) -> AccountBalance: - """Преобразует ответ баланса в dataclass.""" - - data = _expect_mapping(payload) - wallet = data.get("balance") - wallet_data = wallet if isinstance(wallet, Mapping) else data - wallet_data = cast(Payload, wallet_data) - real = _as_float(wallet_data, "real", "amount", "balance") - bonus = _as_float(wallet_data, "bonus") - total = _as_float(wallet_data, "total") or ( - real + bonus if real is not None and bonus is not None else real - ) - return AccountBalance( - user_id=_as_int(data, "user_id", "userId", "id"), - real=real, - bonus=bonus, - total=total, - currency=_as_str(wallet_data, "currency"), - ) - - -def map_operations_history(payload: object) -> OperationsHistoryResult: - """Преобразует историю операций в dataclass.""" - - data = _expect_mapping(payload) - operations = [ - OperationRecord( - 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=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") - ] - return OperationsHistoryResult( - operations=operations, - total=_as_int(data, "total", "count"), - ) - - -def map_ah_user_status(payload: object) -> AhUserStatus: - """Преобразует статус пользователя в ИА.""" - - data = _expect_mapping(payload) - return AhUserStatus( - user_id=_as_int(data, "user_id", "userId", "id"), - is_active=_as_bool(data, "is_active", "isActive", "active"), - role=map_enum_or_unknown( - _as_str(data, "role", "status"), - AccountHierarchyRole, - enum_name="accounts.account_hierarchy_role", - ), - ) - - -def map_employees(payload: object) -> EmployeesResult: - """Преобразует список сотрудников.""" - - data = _expect_mapping(payload) - items = [ - Employee( - employee_id=_as_int(item, "employee_id", "employeeId", "id"), - user_id=_as_int(item, "user_id", "userId"), - name=_as_str(item, "name", "title"), - phone=_as_str(item, "phone"), - email=_as_str(item, "email"), - ) - for item in _as_list(data, "employees", "items", "result") - ] - return EmployeesResult(items=items, total=_as_int(data, "total", "count")) - - -def map_company_phones(payload: object) -> CompanyPhonesResult: - """Преобразует список телефонов компании.""" - - data = _expect_mapping(payload) - items = [ - CompanyPhone( - phone_id=_as_int(item, "id", "phone_id", "phoneId"), - phone=_as_str(item, "phone", "value"), - comment=_as_str(item, "comment", "description"), - ) - for item in _as_list(data, "phones", "items", "result") - ] - return CompanyPhonesResult(items=items) - - -def map_employee_items(payload: object) -> EmployeeItemsResult: - """Преобразует список объявлений сотрудника.""" - - data = _expect_mapping(payload) - items = [ - EmployeeItem( - item_id=_as_int(item, "item_id", "itemId", "id"), - title=_as_str(item, "title"), - 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") - ] - return EmployeeItemsResult(items=items, total=_as_int(data, "total", "count")) - - -def map_action_result(payload: object) -> AccountActionResult: - """Преобразует ответ мутационной операции в dataclass.""" - - if isinstance(payload, Mapping): - data = cast(Payload, payload) - success = bool(data.get("success", True)) - message = _as_str(data, "message", "status") - return AccountActionResult(success=success, message=message) - return AccountActionResult(success=True) - - -__all__ = ( - "map_account_balance", - "map_account_profile", - "map_action_result", - "map_ah_user_status", - "map_company_phones", - "map_employee_items", - "map_employees", - "map_operations_history", -) diff --git a/avito/accounts/models.py b/avito/accounts/models.py index 3ba6a10..d80b6fd 100644 --- a/avito/accounts/models.py +++ b/avito/accounts/models.py @@ -2,20 +2,44 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime +from enum import Enum -from avito.accounts.enums import ( - AccountHierarchyRole, - EmployeeItemStatus, - OperationStatus, - OperationType, -) -from avito.core.serialization import SerializableModel +from avito.core import ApiModel, JsonReader, RequestModel + + +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" @dataclass(slots=True, frozen=True) -class AccountProfile(SerializableModel): +class AccountProfile(ApiModel): """Профиль авторизованного пользователя.""" user_id: int | None @@ -23,9 +47,21 @@ class AccountProfile(SerializableModel): email: str | None phone: str | None + @classmethod + def from_payload(cls, payload: object) -> AccountProfile: + """Преобразует ответ API с профилем пользователя в SDK-модель.""" + + reader = JsonReader(payload) + return cls( + user_id=reader.optional_int("id", "user_id"), + name=reader.optional_str("name", "title"), + email=reader.optional_str("email"), + phone=reader.optional_str("phone"), + ) + @dataclass(slots=True, frozen=True) -class AccountBalance(SerializableModel): +class AccountBalance(ApiModel): """Баланс кошелька пользователя.""" user_id: int | None @@ -34,9 +70,29 @@ class AccountBalance(SerializableModel): total: float | None currency: str | None + @classmethod + def from_payload(cls, payload: object) -> AccountBalance: + """Преобразует ответ API с балансом аккаунта в SDK-модель.""" + + reader = JsonReader(payload) + wallet = reader.mapping("balance") + wallet_reader = JsonReader(wallet if wallet is not None else payload) + real = wallet_reader.optional_float("real", "amount", "balance") + bonus = wallet_reader.optional_float("bonus") + total = wallet_reader.optional_float("total") + if total is None and real is not None: + total = real + bonus if bonus is not None else real + return cls( + user_id=reader.optional_int("user_id", "userId", "id"), + real=real, + bonus=bonus, + total=total, + currency=wallet_reader.optional_str("currency"), + ) + @dataclass(slots=True, frozen=True) -class OperationRecord(SerializableModel): +class OperationRecord(ApiModel): """Операция по аккаунту.""" id: str | None @@ -46,50 +102,96 @@ class OperationRecord(SerializableModel): status: OperationStatus | None description: str | None + @classmethod + def from_payload(cls, payload: object) -> OperationRecord: + """Преобразует JSON-объект операции аккаунта в SDK-модель.""" + + reader = JsonReader(payload) + return cls( + id=reader.optional_str("id", "operation_id"), + created_at=reader.optional_datetime("created_at", "createdAt", "date"), + amount=reader.optional_float("amount", "price", "sum"), + operation_type=reader.enum( + OperationType, + "type", + "operation_type", + "operationType", + unknown=OperationType.UNKNOWN, + ), + status=reader.enum(OperationStatus, "status", unknown=OperationStatus.UNKNOWN), + description=reader.optional_str("description", "title"), + ) + @dataclass(slots=True, frozen=True) -class OperationsHistoryRequest: +class OperationsHistoryRequest(RequestModel): """Фильтр истории операций аккаунта.""" - date_from: str | None = None - date_to: str | None = None - limit: int | None = None - offset: int | None = None + date_from: str + date_to: str def to_payload(self) -> dict[str, object]: """Сериализует фильтр в JSON body.""" - return { - key: value - for key, value in { - "dateFrom": self.date_from, - "dateTo": self.date_to, - "limit": self.limit, - "offset": self.offset, - }.items() - if value is not None - } + return _without_none( + { + "dateTimeFrom": self.date_from, + "dateTimeTo": self.date_to, + } + ) @dataclass(slots=True, frozen=True) -class OperationsHistoryResult(SerializableModel): +class OperationsHistoryResult(ApiModel): """История операций пользователя.""" operations: list[OperationRecord] total: int | None = None + @classmethod + def from_payload(cls, payload: object) -> OperationsHistoryResult: + """Преобразует ответ API с историей операций в SDK-модель.""" + + reader = JsonReader(payload) + result = reader.mapping("result") + data_reader = JsonReader(result) if result is not None else reader + return cls( + operations=[ + OperationRecord.from_payload(item) + for item in data_reader.list("operations", "items", "result") + if isinstance(item, Mapping) + ], + total=data_reader.optional_int("total", "count"), + ) + @dataclass(slots=True, frozen=True) -class AhUserStatus(SerializableModel): +class AhUserStatus(ApiModel): """Статус пользователя в иерархии аккаунтов.""" user_id: int | None is_active: bool | None role: AccountHierarchyRole | None + @classmethod + def from_payload(cls, payload: object) -> AhUserStatus: + """Преобразует ответ API со статусом пользователя в иерархии.""" + + reader = JsonReader(payload) + return cls( + user_id=reader.optional_int("user_id", "userId", "id"), + is_active=reader.optional_bool("is_active", "isActive", "active"), + role=reader.enum( + AccountHierarchyRole, + "role", + "status", + unknown=AccountHierarchyRole.UNKNOWN, + ), + ) + @dataclass(slots=True, frozen=True) -class Employee(SerializableModel): +class Employee(ApiModel): """Сотрудник иерархии аккаунтов.""" employee_id: int | None @@ -98,33 +200,97 @@ class Employee(SerializableModel): phone: str | None email: str | None + @classmethod + def from_payload(cls, payload: object) -> Employee: + """Преобразует JSON-объект сотрудника в SDK-модель.""" + + reader = JsonReader(payload) + return cls( + employee_id=reader.optional_int("employee_id", "employeeId", "id"), + user_id=reader.optional_int("user_id", "userId"), + name=reader.optional_str("name", "title"), + phone=reader.optional_str("phone"), + email=reader.optional_str("email"), + ) + @dataclass(slots=True, frozen=True) -class EmployeesResult(SerializableModel): +class EmployeesResult(ApiModel): """Список сотрудников иерархии.""" items: list[Employee] total: int | None = None + @classmethod + def from_payload(cls, payload: object) -> EmployeesResult: + """Преобразует ответ API со списком сотрудников.""" + + if isinstance(payload, list): + return cls( + items=[ + Employee.from_payload(item) + for item in payload + if isinstance(item, Mapping) + ] + ) + + reader = JsonReader(payload) + return cls( + items=[ + Employee.from_payload(item) + for item in reader.list("employees", "items", "result") + if isinstance(item, Mapping) + ], + total=reader.optional_int("total", "count"), + ) + @dataclass(slots=True, frozen=True) -class CompanyPhone(SerializableModel): +class CompanyPhone(ApiModel): """Телефон компании.""" phone_id: int | None phone: str | None comment: str | None + @classmethod + def from_payload(cls, payload: object) -> CompanyPhone: + """Преобразует JSON-объект телефона компании.""" + + if isinstance(payload, str): + return cls(phone_id=None, phone=payload, comment=None) + + reader = JsonReader(payload) + return cls( + phone_id=reader.optional_int("id", "phone_id", "phoneId"), + phone=reader.optional_str("phone", "value"), + comment=reader.optional_str("comment", "description"), + ) + @dataclass(slots=True, frozen=True) -class CompanyPhonesResult(SerializableModel): +class CompanyPhonesResult(ApiModel): """Список телефонов компании.""" items: list[CompanyPhone] + @classmethod + def from_payload(cls, payload: object) -> CompanyPhonesResult: + """Преобразует ответ API со списком телефонов компании.""" + + reader = JsonReader(payload) + result = reader.mapping("result") + data_reader = JsonReader(result) if result is not None else reader + return cls( + items=[ + CompanyPhone.from_payload(item) + for item in data_reader.list("phones", "items", "result") + ] + ) + @dataclass(slots=True, frozen=True) -class EmployeeItemLinkRequest: +class EmployeeItemLinkRequest(RequestModel): """Запрос на привязку объявлений к сотруднику.""" employee_id: int @@ -134,41 +300,37 @@ class EmployeeItemLinkRequest: def to_payload(self) -> dict[str, object]: """Сериализует запрос привязки в JSON body.""" - return { - key: value - for key, value in { + return _without_none( + { "employeeId": self.employee_id, "itemIds": self.item_ids, "sourceEmployeeId": self.source_employee_id, - }.items() - if value is not None - } + } + ) @dataclass(slots=True, frozen=True) -class EmployeeItemsRequest: +class EmployeeItemsRequest(RequestModel): """Запрос списка объявлений сотрудника.""" employee_id: int - limit: int | None = None - offset: int | None = None + category_id: int + last_item_id: int | None = None def to_payload(self) -> dict[str, object]: """Сериализует фильтр объявлений сотрудника.""" - return { - key: value - for key, value in { + return _without_none( + { "employeeId": self.employee_id, - "limit": self.limit, - "offset": self.offset, - }.items() - if value is not None - } + "categoryId": self.category_id, + "lastItemId": self.last_item_id, + } + ) @dataclass(slots=True, frozen=True) -class EmployeeItem(SerializableModel): +class EmployeeItem(ApiModel): """Объявление сотрудника в иерархии.""" item_id: int | None @@ -176,26 +338,70 @@ class EmployeeItem(SerializableModel): status: EmployeeItemStatus | None price: float | None + @classmethod + def from_payload(cls, payload: object) -> EmployeeItem: + """Преобразует JSON-объект объявления сотрудника.""" + + reader = JsonReader(payload) + return cls( + item_id=reader.optional_int("item_id", "itemId", "id"), + title=reader.optional_str("title"), + status=reader.enum(EmployeeItemStatus, "status", unknown=EmployeeItemStatus.UNKNOWN), + price=reader.optional_float("price"), + ) + @dataclass(slots=True, frozen=True) -class EmployeeItemsResult(SerializableModel): +class EmployeeItemsResult(ApiModel): """Список объявлений сотрудника.""" items: list[EmployeeItem] total: int | None = None + @classmethod + def from_payload(cls, payload: object) -> EmployeeItemsResult: + """Преобразует ответ API со списком объявлений сотрудника.""" + + reader = JsonReader(payload) + return cls( + items=[ + EmployeeItem.from_payload(item) + for item in reader.list("items", "result") + if isinstance(item, Mapping) + ], + total=reader.optional_int("total", "count"), + ) + @dataclass(slots=True, frozen=True) -class AccountActionResult(SerializableModel): +class AccountActionResult(ApiModel): """Результат мутационной операции accounts.""" success: bool message: str | None = None + @classmethod + def from_payload(cls, payload: object) -> AccountActionResult: + """Преобразует ответ API мутационной операции accounts.""" + + if not isinstance(payload, Mapping): + return cls(success=True) + reader = JsonReader(payload) + success = reader.optional_bool("success") + return cls( + success=True if success is None else success, + message=reader.optional_str("message", "status"), + ) + + +def _without_none(payload: Mapping[str, object | None]) -> dict[str, object]: + return {key: value for key, value in payload.items() if value is not None} + __all__ = ( "AccountActionResult", "AccountBalance", + "AccountHierarchyRole", "AccountProfile", "AhUserStatus", "CompanyPhone", @@ -203,10 +409,13 @@ class AccountActionResult(SerializableModel): "Employee", "EmployeeItem", "EmployeeItemLinkRequest", + "EmployeeItemStatus", "EmployeeItemsRequest", "EmployeeItemsResult", "EmployeesResult", "OperationRecord", + "OperationStatus", + "OperationType", "OperationsHistoryRequest", "OperationsHistoryResult", ) diff --git a/avito/accounts/operations.py b/avito/accounts/operations.py new file mode 100644 index 0000000..a9fb40a --- /dev/null +++ b/avito/accounts/operations.py @@ -0,0 +1,84 @@ +"""Operation specs for accounts domain.""" + +from __future__ import annotations + +from avito.accounts.models import ( + AccountActionResult, + AccountBalance, + AccountProfile, + AhUserStatus, + CompanyPhonesResult, + EmployeeItemLinkRequest, + EmployeeItemsRequest, + EmployeeItemsResult, + EmployeesResult, + OperationsHistoryRequest, + OperationsHistoryResult, +) +from avito.core import OperationSpec + +GET_SELF = OperationSpec( + name="accounts.get_self", + method="GET", + path="/core/v1/accounts/self", + response_model=AccountProfile, +) +GET_BALANCE = OperationSpec( + name="accounts.get_balance", + method="GET", + path="/core/v1/accounts/{user_id}/balance/", + response_model=AccountBalance, +) +GET_OPERATIONS_HISTORY = OperationSpec( + name="accounts.get_operations_history", + method="POST", + path="/core/v1/accounts/operations_history/", + request_model=OperationsHistoryRequest, + response_model=OperationsHistoryResult, + retry_mode="enabled", +) +GET_AH_USER_STATUS = OperationSpec( + name="accounts.hierarchy.get_status", + method="GET", + path="/checkAhUserV1", + response_model=AhUserStatus, +) +LIST_EMPLOYEES = OperationSpec( + name="accounts.hierarchy.list_employees", + method="GET", + path="/getEmployeesV1", + response_model=EmployeesResult, +) +LIST_COMPANY_PHONES = OperationSpec( + name="accounts.hierarchy.list_company_phones", + method="GET", + path="/listCompanyPhonesV1", + response_model=CompanyPhonesResult, +) +LINK_ITEMS = OperationSpec( + name="accounts.hierarchy.link_items", + method="POST", + path="/linkItemsV1", + request_model=EmployeeItemLinkRequest, + response_model=AccountActionResult, + retry_mode="enabled", +) +LIST_ITEMS_BY_EMPLOYEE = OperationSpec( + name="accounts.hierarchy.list_items_by_employee", + method="POST", + path="/listItemsByEmployeeIdV1", + request_model=EmployeeItemsRequest, + response_model=EmployeeItemsResult, + retry_mode="enabled", +) + +__all__ = ( + "GET_AH_USER_STATUS", + "GET_BALANCE", + "GET_OPERATIONS_HISTORY", + "GET_SELF", + "LINK_ITEMS", + "LIST_COMPANY_PHONES", + "LIST_EMPLOYEES", + "LIST_ITEMS_BY_EMPLOYEE", +) diff --git a/avito/ads/__init__.py b/avito/ads/__init__.py index c48edbf..1318d22 100644 --- a/avito/ads/__init__.py +++ b/avito/ads/__init__.py @@ -8,28 +8,27 @@ AutoloadProfile, AutoloadReport, ) -from avito.ads.enums import ( - AdsActionStatus, - AutoloadAvitoStatus, - AutoloadFieldType, - AutoloadItemStatus, - AutoloadItemStatusDetail, - AutoloadReportStatus, - ListingStatus, -) from avito.ads.models import ( AccountSpendings, + AdAnalyticsGrouping, AdsActionResult, + AdsActionStatus, AdsListResult, + AdSpendingsGrouping, + AutoloadAvitoStatus, AutoloadFee, AutoloadFeesResult, AutoloadField, AutoloadFieldsResult, + AutoloadFieldType, + AutoloadItemStatus, + AutoloadItemStatusDetail, AutoloadProfileSettings, AutoloadReportDetails, AutoloadReportItem, AutoloadReportItemsResult, AutoloadReportsResult, + AutoloadReportStatus, AutoloadReportSummary, AutoloadTreeNode, AutoloadTreeResult, @@ -40,6 +39,7 @@ LegacyAutoloadReport, Listing, ListingStats, + ListingStatus, SpendingRecord, UpdatePriceResult, UploadResult, @@ -49,7 +49,9 @@ __all__ = ( "AccountSpendings", + "AdAnalyticsGrouping", "Ad", + "AdSpendingsGrouping", "AdsActionResult", "AdsListResult", "AdsActionStatus", diff --git a/avito/ads/client.py b/avito/ads/client.py deleted file mode 100644 index 5f6d491..0000000 --- a/avito/ads/client.py +++ /dev/null @@ -1,704 +0,0 @@ -"""Внутренние section clients для раздела ads.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from avito.ads.enums import ListingStatus -from avito.ads.mappers import ( - map_action_result, - map_ad_item, - map_ads_list, - map_autoload_fees, - map_autoload_fields, - map_autoload_profile, - map_autoload_report_details, - map_autoload_report_items, - map_autoload_reports, - map_autoload_tree, - map_calls_stats, - map_id_mapping, - map_item_analytics, - map_item_stats, - map_legacy_autoload_report, - map_spendings, - map_update_price_result, - map_upload_result, - map_vas_prices, -) -from avito.ads.models import ( - AccountSpendings, - AdsActionResult, - ApplyVasPackageRequest, - ApplyVasRequest, - 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 ( - JsonPage, - PaginatedList, - Paginator, - RequestContext, - Transport, - ValidationError, -) -from avito.core.mapping import request_public_model -from avito.promotion.mappers import map_promotion_action -from avito.promotion.models import PromotionActionResult - - -def _bounded_total(total: int | None, max_items: int | None) -> int | None: - if max_items is None: - return total - if total is None: - return None - return min(total, max_items) - - -def _has_next_ads_page( - *, - page_item_count: int, - collected_count: int, - page_size: int, - total: int | None, - max_items: int | None, - already_collected: int, -) -> bool: - if page_item_count == 0 or page_size <= 0: - return False - if max_items is not None and already_collected + collected_count >= max_items: - return False - if total is not None: - return already_collected + collected_count < min(total, max_items or total) - return page_item_count >= page_size - - -@dataclass(slots=True, frozen=True) -class AdsClient: - """Выполняет HTTP-операции по разделу объявлений.""" - - transport: Transport - - def get_item(self, *, user_id: int, item_id: int) -> Listing: - """Получает одно объявление.""" - - return request_public_model( - self.transport, - "GET", - f"/core/v1/accounts/{user_id}/items/{item_id}/", - context=RequestContext("ads.get_item"), - mapper=map_ad_item, - ) - - def list_items( - self, - *, - user_id: int | None = None, - status: ListingStatus | str | None = None, - limit: int | None = None, - page_size: int | None = None, - offset: int | None = None, - ) -> PaginatedList[Listing]: - """Получает список объявлений пользователя.""" - - resolved_page_size = page_size or limit - start_offset = offset or 0 - first_page_number = ( - start_offset // resolved_page_size + 1 - if resolved_page_size is not None and resolved_page_size > 0 - else 1 - ) - result = request_public_model( - self.transport, - "GET", - "/core/v1/items", - context=RequestContext("ads.list_items"), - mapper=map_ads_list, - params={ - "user_id": user_id, - "status": status, - "per_page": resolved_page_size, - "page": first_page_number, - }, - ) - page_size = resolved_page_size if resolved_page_size and resolved_page_size > 0 else len(result.items) - max_items = limit if limit is not None and limit >= 0 else None - page_offset = start_offset % page_size if page_size > 0 else 0 - available_items = result.items[page_offset:] - first_items = available_items[:max_items] if max_items is not None else available_items - total = _bounded_total(result.total, max_items) - first_page = JsonPage( - items=list(first_items), - total=total, - source_total=result.total, - page=first_page_number, - per_page=page_size if page_size > 0 else None, - has_next_page=_has_next_ads_page( - page_item_count=len(result.items), - collected_count=len(first_items), - page_size=page_size, - total=result.total, - max_items=max_items, - already_collected=0, - ), - ) - return Paginator( - lambda page, cursor: self._fetch_ads_page( - page=page, - user_id=user_id, - status=status, - page_size=page_size, - max_items=max_items, - first_page_number=first_page_number, - ) - ).as_list(start_page=first_page_number, first_page=first_page) - - def _fetch_ads_page( - self, - *, - page: int | None, - user_id: int | None, - status: ListingStatus | str | None, - page_size: int, - max_items: int | None, - first_page_number: int, - ) -> JsonPage[Listing]: - if page is None: - raise ValidationError("Для операции требуется `page`.") - - already_collected = max(page - first_page_number, 0) * page_size - remaining = max_items - already_collected if max_items is not None else None - if remaining is not None and remaining <= 0: - return JsonPage(items=[], total=max_items, page=page, per_page=page_size) - result = request_public_model( - self.transport, - "GET", - "/core/v1/items", - context=RequestContext("ads.list_items"), - mapper=map_ads_list, - params={ - "user_id": user_id, - "status": status, - "per_page": min(page_size, remaining) if remaining is not None else page_size, - "page": page, - }, - ) - items = result.items[:remaining] if remaining is not None else result.items - return JsonPage( - items=list(items), - total=_bounded_total(result.total, max_items), - source_total=result.total, - page=page, - per_page=page_size, - has_next_page=_has_next_ads_page( - page_item_count=len(result.items), - collected_count=len(items), - page_size=page_size, - total=result.total, - max_items=max_items, - already_collected=already_collected, - ), - ) - - 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=idempotency_key is not None), - mapper=map_update_price_result, - json_body=UpdatePriceRequest(price=price).to_payload(), - idempotency_key=idempotency_key, - ) - - -@dataclass(slots=True, frozen=True) -class StatsClient: - """Выполняет HTTP-операции по статистике объявлений.""" - - transport: Transport - - 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( - self.transport, - "POST", - f"/core/v1/accounts/{user_id}/calls/stats/", - context=RequestContext("ads.stats.calls", allow_retry=True), - mapper=map_calls_stats, - json_body=CallsStatsRequest( - item_ids=item_ids, - date_from=date_from, - date_to=date_to, - ).to_payload(), - ) - - 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( - self.transport, - "POST", - f"/stats/v1/accounts/{user_id}/items", - context=RequestContext("ads.stats.items", allow_retry=True), - mapper=map_item_stats, - 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, - item_ids: list[int], - date_from: str | None = None, - date_to: str | None = None, - fields: list[str] | None = None, - ) -> ItemAnalyticsResult: - """Получает аналитику по профилю.""" - - return request_public_model( - self.transport, - "POST", - f"/stats/v2/accounts/{user_id}/items", - context=RequestContext("ads.stats.analytics", allow_retry=True), - mapper=map_item_analytics, - json_body=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, - item_ids: list[int], - date_from: str | None = None, - date_to: str | None = None, - fields: list[str] | None = None, - ) -> AccountSpendings: - """Получает статистику расходов профиля.""" - - return request_public_model( - self.transport, - "POST", - f"/stats/v2/accounts/{user_id}/spendings", - context=RequestContext("ads.stats.spendings", allow_retry=True), - mapper=map_spendings, - json_body=ItemStatsRequest( - item_ids=item_ids, - date_from=date_from, - date_to=date_to, - fields=fields or [], - ).to_payload(), - ) - - -@dataclass(slots=True, frozen=True) -class VasClient: - """Выполняет HTTP-операции VAS и продвижения.""" - - transport: Transport - - def get_prices( - self, *, user_id: int, item_ids: list[int], location_id: int | None = None - ) -> VasPricesResult: - """Получает цены VAS и доступные услуги продвижения.""" - - return request_public_model( - self.transport, - "POST", - f"/core/v1/accounts/{user_id}/vas/prices", - context=RequestContext("ads.vas.prices", allow_retry=True), - mapper=map_vas_prices, - json_body=VasPricesRequest(item_ids=item_ids, location_id=location_id).to_payload(), - ) - - def apply_item_vas( - self, - *, - user_id: int, - item_id: int, - codes: list[str], - idempotency_key: str | None = None, - ) -> PromotionActionResult: - """Применяет дополнительные услуги к объявлению.""" - - 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=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, - idempotency_key=idempotency_key, - ) - - def apply_item_vas_package( - self, - *, - user_id: int, - item_id: int, - package_code: str, - idempotency_key: str | None = None, - ) -> PromotionActionResult: - """Применяет пакет дополнительных услуг.""" - - 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=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, - idempotency_key=idempotency_key, - ) - - def apply_vas_direct( - self, - *, - item_id: int, - codes: list[str], - idempotency_key: str | None = None, - ) -> PromotionActionResult: - """Применяет услуги продвижения через v2 endpoint.""" - - 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=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, - idempotency_key=idempotency_key, - ) - - -@dataclass(slots=True, frozen=True) -class AutoloadClient: - """Выполняет HTTP-операции автозагрузки.""" - - transport: Transport - - def get_profile(self) -> AutoloadProfileSettings: - """Получает профиль пользователя автозагрузки.""" - - return request_public_model( - self.transport, - "GET", - "/autoload/v2/profile", - context=RequestContext("ads.autoload.get_profile"), - mapper=map_autoload_profile, - ) - - 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=idempotency_key is not None), - mapper=map_action_result, - json_body=AutoloadProfileUpdateRequest( - is_enabled=is_enabled, - email=email, - callback_url=callback_url, - ).to_payload(), - idempotency_key=idempotency_key, - ) - - 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=idempotency_key is not None, - ), - mapper=map_upload_result, - json_body=UploadByUrlRequest(url=url).to_payload(), - idempotency_key=idempotency_key, - ) - - def get_node_fields(self, *, node_slug: str) -> AutoloadFieldsResult: - """Получает поля категории по slug.""" - - return request_public_model( - self.transport, - "GET", - f"/autoload/v1/user-docs/node/{node_slug}/fields", - context=RequestContext("ads.autoload.get_node_fields"), - mapper=map_autoload_fields, - ) - - def get_tree(self) -> AutoloadTreeResult: - """Получает дерево категорий автозагрузки.""" - - return request_public_model( - self.transport, - "GET", - "/autoload/v1/user-docs/tree", - context=RequestContext("ads.autoload.get_tree"), - mapper=map_autoload_tree, - ) - - def get_ad_ids_by_avito_ids(self, *, avito_ids: list[int]) -> IdMappingResult: - """Получает ad ids по avito ids.""" - - return request_public_model( - self.transport, - "GET", - "/autoload/v2/items/ad_ids", - context=RequestContext("ads.autoload.get_ad_ids_by_avito_ids"), - mapper=map_id_mapping, - params={"query": ",".join(str(item) for item in avito_ids)}, - ) - - def get_avito_ids_by_ad_ids(self, *, ad_ids: list[int]) -> IdMappingResult: - """Получает avito ids по ad ids.""" - - return request_public_model( - self.transport, - "GET", - "/autoload/v2/items/avito_ids", - context=RequestContext("ads.autoload.get_avito_ids_by_ad_ids"), - mapper=map_id_mapping, - params={"query": ",".join(str(item) for item in ad_ids)}, - ) - - def list_reports( - self, *, limit: int | None = None, offset: int | None = None - ) -> PaginatedList[AutoloadReportSummary]: - """Получает список отчетов автозагрузки.""" - - page_size = limit or 25 - base_offset = offset or 0 - - def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[AutoloadReportSummary]: - current_page = page or 1 - current_offset = base_offset + (current_page - 1) * page_size - result = request_public_model( - self.transport, - "GET", - "/autoload/v2/reports", - context=RequestContext("ads.autoload.list_reports"), - mapper=map_autoload_reports, - params={"limit": page_size, "offset": current_offset}, - ) - return JsonPage( - items=result.items, - total=result.total, - page=current_page, - per_page=page_size, - ) - - return Paginator(fetch_page).as_list(first_page=fetch_page(1, None)) - - def get_items_info(self, *, item_ids: list[int]) -> AutoloadReportItemsResult: - """Получает объявления автозагрузки по ID.""" - - return request_public_model( - self.transport, - "GET", - "/autoload/v2/reports/items", - context=RequestContext("ads.autoload.get_items_info"), - mapper=map_autoload_report_items, - params={"query": ",".join(str(item) for item in item_ids)}, - ) - - def get_report(self, *, report_id: int) -> AutoloadReportDetails: - """Получает статистику по конкретной выгрузке v3.""" - - return request_public_model( - self.transport, - "GET", - f"/autoload/v3/reports/{report_id}", - context=RequestContext("ads.autoload.get_report"), - mapper=map_autoload_report_details, - ) - - def get_last_completed_report(self) -> AutoloadReportDetails: - """Получает статистику по последней выгрузке v3.""" - - return request_public_model( - self.transport, - "GET", - "/autoload/v3/reports/last_completed_report", - context=RequestContext("ads.autoload.get_last_completed_report"), - mapper=map_autoload_report_details, - ) - - def get_report_items(self, *, report_id: int) -> AutoloadReportItemsResult: - """Получает все объявления из конкретной выгрузки.""" - - return request_public_model( - self.transport, - "GET", - f"/autoload/v2/reports/{report_id}/items", - context=RequestContext("ads.autoload.get_report_items"), - mapper=map_autoload_report_items, - ) - - def get_report_fees(self, *, report_id: int) -> AutoloadFeesResult: - """Получает списания по объявлениям выгрузки.""" - - return request_public_model( - self.transport, - "GET", - f"/autoload/v2/reports/{report_id}/items/fees", - context=RequestContext("ads.autoload.get_report_fees"), - mapper=map_autoload_fees, - ) - - -@dataclass(slots=True, frozen=True) -class AutoloadArchiveClient: - """Выполняет архивные HTTP-операции автозагрузки.""" - - transport: Transport - - def get_profile(self) -> AutoloadProfileSettings: - """Получает архивный профиль автозагрузки.""" - - return request_public_model( - self.transport, - "GET", - "/autoload/v1/profile", - context=RequestContext("ads.autoload_archive.get_profile"), - mapper=map_autoload_profile, - ) - - 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=idempotency_key is not None, - ), - mapper=map_action_result, - 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: - """Получает статистику по последней архивной выгрузке.""" - - return request_public_model( - self.transport, - "GET", - "/autoload/v2/reports/last_completed_report", - context=RequestContext("ads.autoload_archive.get_last_completed_report"), - mapper=map_legacy_autoload_report, - ) - - def get_report(self, *, report_id: int) -> LegacyAutoloadReport: - """Получает статистику по конкретной архивной выгрузке.""" - - return request_public_model( - self.transport, - "GET", - f"/autoload/v2/reports/{report_id}", - context=RequestContext("ads.autoload_archive.get_report"), - mapper=map_legacy_autoload_report, - ) - - -__all__ = ("AdsClient", "AutoloadArchiveClient", "AutoloadClient", "StatsClient", "VasClient") diff --git a/avito/ads/domain.py b/avito/ads/domain.py index cdf0d37..2d26d4d 100644 --- a/avito/ads/domain.py +++ b/avito/ads/domain.py @@ -4,48 +4,89 @@ from collections.abc import Sequence from dataclasses import dataclass -from datetime import date, datetime - -from avito.ads.client import ( - AdsClient, - AutoloadArchiveClient, - AutoloadClient, - StatsClient, - VasClient, -) -from avito.ads.enums import ListingStatus + from avito.ads.models import ( AccountSpendings, + AdAnalyticsGroupingInput, AdsActionResult, + AdSpendingsGroupingInput, + ApplyVasDirectRequest, ApplyVasPackageRequest, ApplyVasRequest, AutoloadFeesResult, AutoloadFieldsResult, AutoloadProfileSettings, + AutoloadProfileUpdateRequest, AutoloadReportDetails, AutoloadReportItemsResult, AutoloadReportSummary, AutoloadTreeResult, + CallsStatsRequest, CallsStatsResult, IdMappingResult, + ItemAnalyticsRequest, ItemAnalyticsResult, + ItemStatsRequest, ItemStatsResult, LegacyAutoloadReport, Listing, + ListingStatus, + SpendingsRequest, + UpdatePriceRequest, UpdatePriceResult, + UploadByUrlRequest, UploadResult, + VasPricesRequest, VasPricesResult, ) -from avito.core import PaginatedList, ValidationError +from avito.ads.operations import ( + APPLY_ITEM_VAS, + APPLY_ITEM_VAS_PACKAGE, + APPLY_VAS_DIRECT, + GET_ACCOUNT_SPENDINGS, + GET_AD_IDS_BY_AVITO_IDS, + GET_ARCHIVE_LAST_COMPLETED_REPORT, + GET_ARCHIVE_PROFILE, + GET_ARCHIVE_REPORT, + GET_AUTOLOAD_ITEMS_INFO, + GET_AUTOLOAD_LAST_COMPLETED_REPORT, + GET_AUTOLOAD_NODE_FIELDS, + GET_AUTOLOAD_PROFILE, + GET_AUTOLOAD_REPORT, + GET_AUTOLOAD_REPORT_FEES, + GET_AUTOLOAD_REPORT_ITEMS, + GET_AUTOLOAD_TREE, + GET_AVITO_IDS_BY_AD_IDS, + GET_CALLS_STATS, + GET_ITEM, + GET_ITEM_ANALYTICS, + GET_ITEM_STATS, + GET_VAS_PRICES, + LIST_AUTOLOAD_REPORTS, + LIST_ITEMS, + SAVE_ARCHIVE_PROFILE, + SAVE_AUTOLOAD_PROFILE, + UPDATE_PRICE, + UPLOAD_BY_URL, +) +from avito.core import ( + ApiTimeouts, + JsonPage, + PaginatedList, + Paginator, + RetryOverride, + ValidationError, +) from avito.core.deprecation import deprecated_method from avito.core.domain import DomainObject from avito.core.swagger import swagger_operation from avito.core.validation import ( + DateInput, + serialize_iso_date, validate_non_empty_string, validate_string_items, ) -from avito.promotion.enums import PromotionStatus -from avito.promotion.models import PromotionActionResult +from avito.promotion.models import PromotionActionResult, PromotionStatus def _preview_result( @@ -64,26 +105,37 @@ def _preview_result( ) -StatsDate = date | datetime | str +StatsDate = DateInput -def _serialize_stats_date(value: StatsDate | None) -> str | None: - if value is None: +def _serialize_stats_date(value: StatsDate) -> str: + return serialize_iso_date("date", value) + + +def _bounded_total(total: int | None, max_items: int | None) -> int | None: + if max_items is None: + return total + if total is None: return None - if isinstance(value, datetime): - return value.date().isoformat() - if isinstance(value, date): - return value.isoformat() - normalized = value.strip() - if not normalized: - raise ValidationError("Дата статистики не должна быть пустой строкой.") - try: - return datetime.fromisoformat(normalized.replace("Z", "+00:00")).date().isoformat() - except ValueError: - try: - return date.fromisoformat(normalized).isoformat() - except ValueError as exc: - raise ValidationError("Дата статистики должна быть в ISO-формате.") from exc + return min(total, max_items) + + +def _has_next_ads_page( + *, + page_item_count: int, + collected_count: int, + page_size: int, + total: int | None, + max_items: int | None, + already_collected: int, +) -> bool: + if page_item_count == 0 or page_size <= 0: + return False + if max_items is not None and already_collected + collected_count >= max_items: + return False + if total is not None: + return already_collected + collected_count < min(total, max_items or total) + return page_item_count >= page_size @dataclass(slots=True, frozen=True) @@ -103,14 +155,32 @@ class Ad(DomainObject): spec="Объявления.json", operation_id="getItemInfo", ) - def get(self) -> Listing: + def get( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> Listing: """Получает объявление по `item_id`. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `Listing` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ item_id, user_id = self._require_ids() - return AdsClient(self.transport).get_item(user_id=user_id, item_id=item_id) + return self._execute( + GET_ITEM, + path_params={"user_id": user_id, "item_id": item_id}, + timeout=timeout, + retry=retry, + ) @swagger_operation( "GET", @@ -125,22 +195,84 @@ def list( limit: int | None = None, page_size: int | None = None, offset: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> PaginatedList[Listing]: - """Получает список объявлений. + """Возвращает объявления аккаунта с ленивой пагинацией. + + Аргументы: + status: фильтрует результат по статусу. + limit: ограничивает размер возвращаемой выборки. + page_size: задает размер страницы для ленивой пагинации. + offset: задает смещение первой записи в выборке. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + Ленивый `PaginatedList[Listing]`; первая страница загружается при создании, следующие страницы - при итерации. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ user_id = self._resolve_user_id(self.user_id) - return AdsClient(self.transport).list_items( - user_id=user_id, - status=status, - limit=limit, - page_size=page_size, - offset=offset, + resolved_page_size = page_size or limit + start_offset = offset or 0 + first_page_number = ( + start_offset // resolved_page_size + 1 + if resolved_page_size is not None and resolved_page_size > 0 + else 1 ) + result = self._execute( + LIST_ITEMS, + query={ + "user_id": user_id, + "status": status, + "per_page": resolved_page_size, + "page": first_page_number, + }, + timeout=timeout, + retry=retry, + ) + list_result = result + page_size = ( + resolved_page_size + if resolved_page_size and resolved_page_size > 0 + else len(list_result.items) + ) + max_items = limit if limit is not None and limit >= 0 else None + page_offset = start_offset % page_size if page_size > 0 else 0 + available_items = list_result.items[page_offset:] + first_items = available_items[:max_items] if max_items is not None else available_items + first_page = JsonPage( + items=list(first_items), + total=_bounded_total(list_result.total, max_items), + source_total=list_result.total, + page=first_page_number, + per_page=page_size if page_size > 0 else None, + has_next_page=_has_next_ads_page( + page_item_count=len(list_result.items), + collected_count=len(first_items), + page_size=page_size, + total=list_result.total, + max_items=max_items, + already_collected=0, + ), + ) + return Paginator( + lambda page, cursor: self._fetch_ads_page( + page=page, + user_id=user_id, + status=status, + page_size=page_size, + max_items=max_items, + first_page_number=first_page_number, + ) + ).as_list(start_page=first_page_number, first_page=first_page) @swagger_operation( "POST", @@ -154,19 +286,80 @@ def update_price( *, price: int | float, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> UpdatePriceResult: """Обновляет цену текущего объявления. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + price: новое значение цены. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Возвращает: + `UpdatePriceResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ item_id = self._require_item_id() - return AdsClient(self.transport).update_price( - item_id=item_id, - price=price, + return self._execute( + UPDATE_PRICE, + path_params={"item_id": item_id}, + request=UpdatePriceRequest(price=price), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + def _fetch_ads_page( + self, + *, + page: int | None, + user_id: int | None, + status: ListingStatus | str | None, + page_size: int, + max_items: int | None, + first_page_number: int, + ) -> JsonPage[Listing]: + if page is None: + raise ValidationError("Для операции требуется `page`.") + + already_collected = max(page - first_page_number, 0) * page_size + remaining = max_items - already_collected if max_items is not None else None + if remaining is not None and remaining <= 0: + return JsonPage(items=[], total=max_items, page=page, per_page=page_size) + result = self._execute( + LIST_ITEMS, + query={ + "user_id": user_id, + "status": status, + "per_page": min(page_size, remaining) if remaining is not None else page_size, + "page": page, + }, + ) + list_result = result + items = list_result.items[:remaining] if remaining is not None else list_result.items + return JsonPage( + items=list(items), + total=_bounded_total(list_result.total, max_items), + source_total=list_result.total, + page=page, + per_page=page_size, + has_next_page=_has_next_ads_page( + page_item_count=len(list_result.items), + collected_count=len(items), + page_size=page_size, + total=list_result.total, + max_items=max_items, + already_collected=already_collected, + ), ) def _require_item_id(self) -> int: @@ -196,26 +389,51 @@ class AdStats(DomainObject): "/core/v1/accounts/{user_id}/calls/stats", spec="Объявления.json", operation_id="postCallsStats", + method_args={ + "date_from": "body.dateFrom", + "date_to": "body.dateTo", + }, ) def get_calls_stats( self, *, + date_from: StatsDate, + date_to: StatsDate, item_ids: list[int] | None = None, - date_from: StatsDate | None = None, - date_to: StatsDate | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> CallsStatsResult: """Получает статистику звонков. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + date_from: начальная дата или дата-время периода. + date_to: конечная дата или дата-время периода. + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CallsStatsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ user_id = self._require_user_id() resolved_item_ids = item_ids or ([int(self.item_id)] if self.item_id is not None else []) - return StatsClient(self.transport).get_calls_stats( - user_id=user_id, - item_ids=resolved_item_ids, - date_from=_serialize_stats_date(date_from), - date_to=_serialize_stats_date(date_to), + return self._execute( + GET_CALLS_STATS, + path_params={"user_id": user_id}, + request=CallsStatsRequest( + item_ids=resolved_item_ids, + date_from=_serialize_stats_date(date_from), + date_to=_serialize_stats_date(date_to), + ), + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -223,28 +441,54 @@ def get_calls_stats( "/stats/v1/accounts/{user_id}/items", spec="Объявления.json", operation_id="itemStatsShallow", + method_args={ + "date_from": "body.dateFrom", + "date_to": "body.dateTo", + }, ) def get_item_stats( self, *, + date_from: StatsDate, + date_to: StatsDate, item_ids: list[int] | None = None, - date_from: StatsDate | None = None, - date_to: StatsDate | None = None, fields: list[str] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> ItemStatsResult: """Получает статистику по списку объявлений. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + date_from: начальная дата или дата-время периода. + date_to: конечная дата или дата-время периода. + item_ids: список идентификаторов объявлений. + fields: список запрошенных полей. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ItemStatsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ user_id = self._require_user_id() resolved_item_ids = item_ids or ([int(self.item_id)] if self.item_id is not None else []) - return StatsClient(self.transport).get_item_stats( - user_id=user_id, - item_ids=resolved_item_ids, - date_from=_serialize_stats_date(date_from), - date_to=_serialize_stats_date(date_to), - fields=fields or [], + return self._execute( + GET_ITEM_STATS, + path_params={"user_id": user_id}, + request=ItemStatsRequest( + item_ids=resolved_item_ids, + date_from=_serialize_stats_date(date_from), + date_to=_serialize_stats_date(date_to), + fields=fields or [], + ), + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -252,28 +496,65 @@ def get_item_stats( "/stats/v2/accounts/{user_id}/items", spec="Объявления.json", operation_id="itemAnalytics", + method_args={ + "date_from": "body.dateFrom", + "date_to": "body.dateTo", + "metrics": "body.metrics", + "grouping": "body.grouping", + "limit": "body.limit", + "offset": "body.offset", + }, ) def get_item_analytics( self, *, + date_from: StatsDate, + date_to: StatsDate, + metrics: list[str], + grouping: AdAnalyticsGroupingInput, + limit: int, + offset: int, item_ids: list[int] | None = None, - date_from: StatsDate | None = None, - date_to: StatsDate | None = None, - fields: list[str] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> ItemAnalyticsResult: """Получает аналитику по профилю. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + date_from: начальная дата или дата-время периода. + date_to: конечная дата или дата-время периода. + metrics: список метрик статистики, которые нужно вернуть. + grouping: группировка статистики или расходов. + limit: максимальное количество элементов в ответе. + offset: смещение выборки. + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ItemAnalyticsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ user_id = self._require_user_id() - resolved_item_ids = item_ids or ([int(self.item_id)] if self.item_id is not None else []) - return StatsClient(self.transport).get_item_analytics( - user_id=user_id, - item_ids=resolved_item_ids, - date_from=_serialize_stats_date(date_from), - date_to=_serialize_stats_date(date_to), - fields=fields or [], + return self._execute( + GET_ITEM_ANALYTICS, + path_params={"user_id": user_id}, + request=ItemAnalyticsRequest( + date_from=_serialize_stats_date(date_from), + date_to=_serialize_stats_date(date_to), + metrics=metrics, + grouping=grouping, + limit=limit, + offset=offset, + ), + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -281,28 +562,59 @@ def get_item_analytics( "/stats/v2/accounts/{user_id}/spendings", spec="Объявления.json", operation_id="accountSpendings", + method_args={ + "date_from": "body.dateFrom", + "date_to": "body.dateTo", + "spending_types": "body.spendingTypes", + "grouping": "body.grouping", + }, ) def get_account_spendings( self, *, + date_from: StatsDate, + date_to: StatsDate, + spending_types: list[str], + grouping: AdSpendingsGroupingInput, item_ids: list[int] | None = None, - date_from: StatsDate | None = None, - date_to: StatsDate | None = None, - fields: list[str] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> AccountSpendings: """Получает статистику расходов профиля. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + date_from: начальная дата или дата-время периода. + date_to: конечная дата или дата-время периода. + spending_types: типы расходов, включаемые в отчет. + grouping: группировка статистики или расходов. + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AccountSpendings` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ user_id = self._require_user_id() resolved_item_ids = item_ids or ([int(self.item_id)] if self.item_id is not None else []) - return StatsClient(self.transport).get_account_spendings( - user_id=user_id, - item_ids=resolved_item_ids, - date_from=_serialize_stats_date(date_from), - date_to=_serialize_stats_date(date_to), - fields=fields or [], + return self._execute( + GET_ACCOUNT_SPENDINGS, + path_params={"user_id": user_id}, + request=SpendingsRequest( + date_from=_serialize_stats_date(date_from), + date_to=_serialize_stats_date(date_to), + spending_types=spending_types, + grouping=grouping, + item_ids=resolved_item_ids, + ), + timeout=timeout, + retry=retry, ) def _require_user_id(self) -> int: @@ -328,18 +640,38 @@ class AdPromotion(DomainObject): method_args={"item_ids": "body.item_ids"}, ) def get_vas_prices( - self, *, item_ids: list[int], location_id: int | None = None + self, + *, + item_ids: list[int], + location_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> VasPricesResult: """Получает цены продвижения и доступные услуги. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + item_ids: список идентификаторов объявлений. + location_id: идентификатор локации для расчета доступности или цены услуги. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `VasPricesResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ user_id = self._require_user_id() - return VasClient(self.transport).get_prices( - user_id=user_id, - item_ids=item_ids, - location_id=location_id, + return self._execute( + GET_VAS_PRICES, + path_params={"user_id": user_id}, + request=VasPricesRequest(item_ids=item_ids, location_id=location_id), + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -347,27 +679,41 @@ def get_vas_prices( "/core/v1/accounts/{user_id}/items/{item_id}/vas", spec="Объявления.json", operation_id="putItemVas", - method_args={"codes": "body.vas_id"}, + method_args={"vas_id": "body.vas_id"}, ) def apply_vas( self, *, - codes: list[str], + vas_id: str, dry_run: bool = False, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> PromotionActionResult: """Применяет дополнительные услуги к объявлению. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + vas_id: идентификатор VAS-услуги. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. - При `dry_run=True` payload строится без вызова транспорта. + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ item_id, user_id = self._require_ids() - validate_string_items("codes", codes) - request_payload = ApplyVasRequest(codes=codes).to_payload() + validate_non_empty_string("vas_id", vas_id) + request_payload = ApplyVasRequest(vas_id=vas_id).to_payload() target: dict[str, object] = {"item_id": item_id, "user_id": user_id} if dry_run: return _preview_result( @@ -375,11 +721,19 @@ def apply_vas( target=target, request_payload=request_payload, ) - return VasClient(self.transport).apply_item_vas( - user_id=user_id, - item_id=item_id, - codes=codes, + payload = self._execute( + APPLY_ITEM_VAS, + path_params={"user_id": user_id, "item_id": item_id}, + request=ApplyVasRequest(vas_id=vas_id), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="apply_vas", + target=target, + request_payload=request_payload, ) @swagger_operation( @@ -395,14 +749,28 @@ def apply_vas_package( package_code: str, dry_run: bool = False, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> PromotionActionResult: """Применяет пакет дополнительных услуг. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + package_code: код пакета продвижения. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - При `dry_run=True` payload строится без вызова транспорта. + Возвращает: + `PromotionActionResult` с типизированными данными ответа. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ item_id, user_id = self._require_ids() @@ -415,11 +783,19 @@ def apply_vas_package( target=target, request_payload=request_payload, ) - return VasClient(self.transport).apply_item_vas_package( - user_id=user_id, - item_id=item_id, - package_code=package_code, + payload = self._execute( + APPLY_ITEM_VAS_PACKAGE, + path_params={"user_id": user_id, "item_id": item_id}, + request=ApplyVasPackageRequest(package_code=package_code), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="apply_vas_package", + target=target, + request_payload=request_payload, ) @swagger_operation( @@ -427,25 +803,41 @@ def apply_vas_package( "/core/v2/items/{item_id}/vas", spec="Объявления.json", operation_id="applyVas", - method_args={"codes": "body.slugs"}, + method_args={"slugs": "body.slugs"}, ) def apply_vas_direct( self, *, - codes: list[str], + slugs: list[str], dry_run: bool = False, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> PromotionActionResult: """Применяет услуги продвижения через прямой v2 endpoint. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + slugs: slug-идентификаторы узлов дерева категорий. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - При `dry_run=True` payload строится без вызова транспорта. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ item_id = self._require_item_id() - validate_string_items("codes", codes) - request_payload = ApplyVasRequest(codes=codes).to_payload() + validate_string_items("slugs", slugs) + request_payload = ApplyVasDirectRequest(slugs=slugs).to_payload() target: dict[str, object] = {"item_id": item_id} if dry_run: return _preview_result( @@ -453,10 +845,19 @@ def apply_vas_direct( target=target, request_payload=request_payload, ) - return VasClient(self.transport).apply_vas_direct( - item_id=item_id, - codes=codes, + payload = self._execute( + APPLY_VAS_DIRECT, + path_params={"item_id": item_id}, + request=ApplyVasDirectRequest(slugs=slugs), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="apply_vas_direct", + target=target, + request_payload=request_payload, ) def _require_item_id(self) -> int: @@ -487,40 +888,92 @@ class AutoloadProfile(DomainObject): spec="Автозагрузка.json", operation_id="getProfileV2", ) - def get(self) -> AutoloadProfileSettings: + def get( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadProfileSettings: """Получает профиль автозагрузки. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadProfileSettings` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return AutoloadClient(self.transport).get_profile() + return self._execute(GET_AUTOLOAD_PROFILE, timeout=timeout, retry=retry) @swagger_operation( "POST", "/autoload/v2/profile", spec="Автозагрузка.json", operation_id="createOrUpdateProfileV2", + method_args={ + "is_enabled": "body.autoload_enabled", + "feed_url": "body.feeds_data", + "report_email": "body.report_email", + "schedule_rate": "body.schedule[].rate", + }, ) def save( self, *, - is_enabled: bool | None = None, - email: str | None = None, - callback_url: str | None = None, + is_enabled: bool, + feed_url: str, + report_email: str, + schedule_rate: int, + schedule_weekdays: list[int] | None = None, + schedule_time_slots: list[int] | None = None, + feed_name: str | None = None, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> AdsActionResult: """Сохраняет профиль автозагрузки. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + is_enabled: включает или отключает профиль автозагрузки. + feed_url: URL фида автозагрузки. + report_email: email для отправки отчетов автозагрузки. + schedule_rate: ставка расписания продвижения. + schedule_weekdays: дни недели для расписания; если не передано, используется полный недельный набор. + schedule_time_slots: временные интервалы расписания; если не передано, используется первый слот. + feed_name: имя фида автозагрузки. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AdsActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return AutoloadClient(self.transport).save_profile( - is_enabled=is_enabled, - email=email, - callback_url=callback_url, + return self._execute( + SAVE_AUTOLOAD_PROFILE, + request=AutoloadProfileUpdateRequest( + is_enabled=is_enabled, + report_email=report_email, + schedule_rate=schedule_rate, + schedule_weekdays=schedule_weekdays or [0, 1, 2, 3, 4, 5, 6], + schedule_time_slots=schedule_time_slots or [0], + feed_name=feed_name, + feed_url=feed_url, + ), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -530,17 +983,39 @@ def save( operation_id="upload", method_args={"url": "constant.url"}, ) - def upload_by_url(self, *, url: str, idempotency_key: str | None = None) -> UploadResult: + def upload_by_url( + self, + *, + url: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> UploadResult: """Загружает файл по ссылке. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + url: URL источника данных. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `UploadResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return AutoloadClient(self.transport).upload_by_url( - url=url, + return self._execute( + UPLOAD_BY_URL, + request=UploadByUrlRequest(url=url), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -549,13 +1024,26 @@ def upload_by_url(self, *, url: str, idempotency_key: str | None = None) -> Uplo spec="Автозагрузка.json", operation_id="userDocsTree", ) - def get_tree(self) -> AutoloadTreeResult: + def get_tree( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadTreeResult: """Получает дерево категорий. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadTreeResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return AutoloadClient(self.transport).get_tree() + return self._execute(GET_AUTOLOAD_TREE, timeout=timeout, retry=retry) @swagger_operation( "GET", @@ -564,13 +1052,36 @@ def get_tree(self) -> AutoloadTreeResult: operation_id="userDocsNodeFields", method_args={"node_slug": "path.node_slug"}, ) - def get_node_fields(self, *, node_slug: str) -> AutoloadFieldsResult: + def get_node_fields( + self, + *, + node_slug: str, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutoloadFieldsResult: """Получает поля категории. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + node_slug: slug узла дерева категорий. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadFieldsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return AutoloadClient(self.transport).get_node_fields(node_slug=node_slug) + return self._execute( + GET_AUTOLOAD_NODE_FIELDS, + path_params={"node_slug": node_slug}, + timeout=timeout, + retry=retry, + ) @dataclass(slots=True, frozen=True) @@ -589,14 +1100,32 @@ class AutoloadReport(DomainObject): spec="Автозагрузка.json", operation_id="getReportByIdV3", ) - def get(self) -> AutoloadReportDetails: + def get( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadReportDetails: """Получает конкретный отчет v3. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadReportDetails` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ report_id = self._require_report_id() - return AutoloadClient(self.transport).get_report(report_id=report_id) + return self._execute( + GET_AUTOLOAD_REPORT, + path_params={"report_id": report_id}, + timeout=timeout, + retry=retry, + ) @swagger_operation( "GET", @@ -605,16 +1134,53 @@ def get(self) -> AutoloadReportDetails: operation_id="getReportsV2", ) def list( - self, *, limit: int | None = None, offset: int | None = None + self, + *, + limit: int | None = None, + offset: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> PaginatedList[AutoloadReportSummary]: - """Получает список отчетов автозагрузки. + """Возвращает отчеты Автозагрузки с ленивой пагинацией. + + Аргументы: + limit: ограничивает размер возвращаемой выборки. + offset: задает смещение первой записи в выборке. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + Ленивый `PaginatedList[AutoloadReportSummary]`; первая страница загружается при создании, следующие страницы - при итерации. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return AutoloadClient(self.transport).list_reports(limit=limit, offset=offset) + page_size = limit or 25 + base_offset = offset or 0 + + def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[AutoloadReportSummary]: + current_page = page or 1 + current_offset = base_offset + (current_page - 1) * page_size + result = self._execute( + LIST_AUTOLOAD_REPORTS, + query={"limit": page_size, "offset": current_offset}, + timeout=timeout, + retry=retry, + ) + reports = result + return JsonPage( + items=reports.items, + total=reports.total, + page=current_page, + per_page=page_size, + ) + + return Paginator(fetch_page).as_list(first_page=fetch_page(1, None)) @swagger_operation( "GET", @@ -622,13 +1188,26 @@ def list( spec="Автозагрузка.json", operation_id="getLastCompletedReportV3", ) - def get_last_completed(self) -> AutoloadReportDetails: + def get_last_completed( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadReportDetails: """Получает последний завершенный отчет. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadReportDetails` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return AutoloadClient(self.transport).get_last_completed_report() + return self._execute(GET_AUTOLOAD_LAST_COMPLETED_REPORT, timeout=timeout, retry=retry) @swagger_operation( "GET", @@ -636,16 +1215,32 @@ def get_last_completed(self) -> AutoloadReportDetails: spec="Автозагрузка.json", operation_id="getReportItemsById", ) - def get_items(self) -> AutoloadReportItemsResult: - """Получает объявления из отчета. + def get_items( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadReportItemsResult: + """Возвращает позиции выбранного отчета Автозагрузки. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadReportItemsResult` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ report_id = self._require_report_id() - return AutoloadClient(self.transport).get_report_items(report_id=report_id) + return self._execute( + GET_AUTOLOAD_REPORT_ITEMS, + path_params={"report_id": report_id}, + timeout=timeout, + retry=retry, + ) @swagger_operation( "GET", @@ -653,14 +1248,32 @@ def get_items(self) -> AutoloadReportItemsResult: spec="Автозагрузка.json", operation_id="getReportItemsFeesById", ) - def get_fees(self) -> AutoloadFeesResult: + def get_fees( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadFeesResult: """Получает списания по объявлениям отчета. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadFeesResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ report_id = self._require_report_id() - return AutoloadClient(self.transport).get_report_fees(report_id=report_id) + return self._execute( + GET_AUTOLOAD_REPORT_FEES, + path_params={"report_id": report_id}, + timeout=timeout, + retry=retry, + ) @swagger_operation( "GET", @@ -669,13 +1282,36 @@ def get_fees(self) -> AutoloadFeesResult: operation_id="getAdIdsByAvitoIds", method_args={"avito_ids": "query.query"}, ) - def get_ad_ids_by_avito_ids(self, *, avito_ids: Sequence[int]) -> IdMappingResult: + def get_ad_ids_by_avito_ids( + self, + *, + avito_ids: Sequence[int], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> IdMappingResult: """Получает ad ids по avito ids. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + avito_ids: список идентификаторов объявлений на Avito. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `IdMappingResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return AutoloadClient(self.transport).get_ad_ids_by_avito_ids(avito_ids=list(avito_ids)) + return self._execute( + GET_AD_IDS_BY_AVITO_IDS, + query={"query": ",".join(str(item) for item in avito_ids)}, + timeout=timeout, + retry=retry, + ) @swagger_operation( "GET", @@ -684,13 +1320,36 @@ def get_ad_ids_by_avito_ids(self, *, avito_ids: Sequence[int]) -> IdMappingResul operation_id="getAvitoIdsByAdIds", method_args={"ad_ids": "query.query"}, ) - def get_avito_ids_by_ad_ids(self, *, ad_ids: Sequence[int]) -> IdMappingResult: + def get_avito_ids_by_ad_ids( + self, + *, + ad_ids: Sequence[int], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> IdMappingResult: """Получает avito ids по ad ids. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + ad_ids: список внешних идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `IdMappingResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return AutoloadClient(self.transport).get_avito_ids_by_ad_ids(ad_ids=list(ad_ids)) + return self._execute( + GET_AVITO_IDS_BY_AD_IDS, + query={"query": ",".join(str(item) for item in ad_ids)}, + timeout=timeout, + retry=retry, + ) @swagger_operation( "GET", @@ -699,13 +1358,36 @@ def get_avito_ids_by_ad_ids(self, *, ad_ids: Sequence[int]) -> IdMappingResult: operation_id="getAutoloadItemsInfoV2", method_args={"item_ids": "query.query"}, ) - def get_items_info(self, *, item_ids: Sequence[int]) -> AutoloadReportItemsResult: + def get_items_info( + self, + *, + item_ids: Sequence[int], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutoloadReportItemsResult: """Получает информацию по объявлениям автозагрузки. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadReportItemsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return AutoloadClient(self.transport).get_items_info(item_ids=list(item_ids)) + return self._execute( + GET_AUTOLOAD_ITEMS_INFO, + query={"query": ",".join(str(item) for item in item_ids)}, + timeout=timeout, + retry=retry, + ) def _require_report_id(self) -> int: if self.report_id is None: @@ -737,15 +1419,26 @@ class AutoloadArchive(DomainObject): removal_version="1.3.0", deprecated_since="1.1.0", ) - def get_profile(self) -> AutoloadProfileSettings: + def get_profile( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadProfileSettings: """Получает архивный профиль автозагрузки. - Deprecated: используйте `autoload_profile().get`; удаление в версии 1.3.0. + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadProfileSettings` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return AutoloadArchiveClient(self.transport).get_profile() + return self._execute(GET_ARCHIVE_PROFILE, timeout=timeout, retry=retry) @swagger_operation( "POST", @@ -754,6 +1447,12 @@ def get_profile(self) -> AutoloadProfileSettings: operation_id="createOrUpdateProfile", deprecated=True, legacy=True, + method_args={ + "is_enabled": "body.autoload_enabled", + "upload_url": "body.upload_url", + "report_email": "body.report_email", + "schedule_rate": "body.schedule[].rate", + }, ) @deprecated_method( symbol="AutoloadArchive.save_profile", @@ -764,25 +1463,53 @@ def get_profile(self) -> AutoloadProfileSettings: def save_profile( self, *, - is_enabled: bool | None = None, - email: str | None = None, - callback_url: str | None = None, + is_enabled: bool, + upload_url: str, + report_email: str, + schedule_rate: int, + schedule_weekdays: list[int] | None = None, + schedule_time_slots: list[int] | None = None, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> AdsActionResult: """Сохраняет архивный профиль автозагрузки. - Deprecated: используйте `autoload_profile().save`; удаление в версии 1.3.0. - - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + is_enabled: включает или отключает профиль автозагрузки. + upload_url: URL фида автозагрузки. + report_email: email для отправки отчетов автозагрузки. + schedule_rate: ставка расписания продвижения. + schedule_weekdays: дни недели для расписания; если не передано, используется полный недельный набор. + schedule_time_slots: временные интервалы расписания; если не передано, используется первый слот. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AdsActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return AutoloadArchiveClient(self.transport).save_profile( - is_enabled=is_enabled, - email=email, - callback_url=callback_url, + return self._execute( + SAVE_ARCHIVE_PROFILE, + request=AutoloadProfileUpdateRequest( + is_enabled=is_enabled, + report_email=report_email, + schedule_rate=schedule_rate, + schedule_weekdays=schedule_weekdays or [0, 1, 2, 3, 4, 5, 6], + schedule_time_slots=schedule_time_slots or [0], + upload_url=upload_url, + ), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -799,15 +1526,26 @@ def save_profile( removal_version="1.3.0", deprecated_since="1.1.0", ) - def get_last_completed_report(self) -> LegacyAutoloadReport: + def get_last_completed_report( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> LegacyAutoloadReport: """Получает архивную статистику по последней выгрузке. - Deprecated: используйте `autoload_report().get_last_completed`; удаление в версии 1.3.0. + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `LegacyAutoloadReport` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return AutoloadArchiveClient(self.transport).get_last_completed_report() + return self._execute(GET_ARCHIVE_LAST_COMPLETED_REPORT, timeout=timeout, retry=retry) @swagger_operation( "GET", @@ -823,16 +1561,32 @@ def get_last_completed_report(self) -> LegacyAutoloadReport: removal_version="1.3.0", deprecated_since="1.1.0", ) - def get_report(self) -> LegacyAutoloadReport: + def get_report( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> LegacyAutoloadReport: """Получает архивную статистику по конкретной выгрузке. - Deprecated: используйте `autoload_report().get`; удаление в версии 1.3.0. + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Возвращает: + `LegacyAutoloadReport` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ report_id = self._require_report_id() - return AutoloadArchiveClient(self.transport).get_report(report_id=report_id) + return self._execute( + GET_ARCHIVE_REPORT, + path_params={"report_id": report_id}, + timeout=timeout, + retry=retry, + ) def _require_report_id(self) -> int: if self.report_id is None: diff --git a/avito/ads/enums.py b/avito/ads/enums.py deleted file mode 100644 index 668f769..0000000 --- a/avito/ads/enums.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Enum-значения раздела ads.""" - -from __future__ import annotations - -from enum import Enum - - -class ListingStatus(str, Enum): - """Статус объявления.""" - - UNKNOWN = "__unknown__" - ACTIVE = "active" - REMOVED = "removed" - OLD = "old" - BLOCKED = "blocked" - REJECTED = "rejected" - NOT_FOUND = "not_found" - ANOTHER_USER = "another_user" - - -class AdsActionStatus(str, Enum): - """Статус мутационной операции ads.""" - - UNKNOWN = "__unknown__" - APPLIED = "applied" - UPDATED = "updated" - - -class AutoloadFieldType(str, Enum): - """Тип поля автозагрузки.""" - - UNKNOWN = "__unknown__" - STRING = "string" - INTEGER = "integer" - FLOAT = "float" - INPUT = "input" - SELECT = "select" - CHECKBOX = "checkbox" - - -class AutoloadReportStatus(str, Enum): - """Статус отчета автозагрузки.""" - - UNKNOWN = "__unknown__" - DONE = "done" - PROCESSING = "processing" - SUCCESS = "success" - SUCCESS_WARNING = "success_warning" - ERROR = "error" - # Legacy item statuses kept for backward compatibility. - PROBLEM = "problem" - NOT_PUBLISH = "not_publish" - WILL_PUBLISH_LATER = "will_publish_later" - DUPLICATE = "duplicate" - WITHOUT_ID = "without_id" - DELETED = "deleted" - UPSTREAM_UNKNOWN = "unknown" - - -class AutoloadItemStatus(str, Enum): - """Статус объявления в отчете автозагрузки.""" - - UNKNOWN = "__unknown__" - SUCCESS = "success" - PROBLEM = "problem" - ERROR = "error" - NOT_PUBLISH = "not_publish" - WILL_PUBLISH_LATER = "will_publish_later" - DUPLICATE = "duplicate" - WITHOUT_ID = "without_id" - DELETED = "deleted" - UPSTREAM_UNKNOWN = "unknown" - - -class AutoloadItemStatusDetail(str, Enum): - """Подробный статус объявления в отчете автозагрузки.""" - - UNKNOWN = "__unknown__" - SUCCESS_ADDED = "success_added" - SUCCESS_ACTIVATED = "success_activated" - SUCCESS_ACTIVATED_UPDATED = "success_activated_updated" - SUCCESS_UPDATED = "success_updated" - SUCCESS_SKIPPED = "success_skipped" - PROBLEM_OBSOLETE = "problem_obsolete" - PROBLEM_PARAMS_CRITICAL = "problem_params_critical" - PROBLEM_PARAMS = "problem_params" - PROBLEM_PHONE = "problem_phone" - PROBLEM_IMAGES = "problem_images" - PROBLEM_VAS = "problem_vas" - PROBLEM_OTHER = "problem_other" - PROBLEM_SEVERAL = "problem_several" - ERROR_FEE = "error_fee" - ERROR_PARAMS = "error_params" - ERROR_PHONE = "error_phone" - ERROR_REJECTED = "error_rejected" - ERROR_BLOCKED = "error_blocked" - ERROR_DELETED = "error_deleted" - ERROR_OTHER = "error_other" - ERROR_SEVERAL = "error_several" - STOPPED_END_DATE_COMPLETE = "stopped_end_date_complete" - STOPPED_END_DATE_ERROR = "stopped_end_date_error" - DATE_IN_FUTURE = "date_in_future" - PUBLISH_LATER = "publish_later" - LINKER = "linker" - REMOVED_COMPLETE = "removed_complete" - REMOVED_ERROR = "removed_error" - NEED_SYNC = "need_sync" - DUPLICATE = "duplicate" - WITHOUT_ID = "without_id" - - -class AutoloadAvitoStatus(str, Enum): - """Статус объявления на Авито из отчета автозагрузки.""" - - UNKNOWN = "__unknown__" - ACTIVE = "active" - OLD = "old" - BLOCKED = "blocked" - REJECTED = "rejected" - ARCHIVED = "archived" - REMOVED = "removed" - - -__all__ = ( - "AdsActionStatus", - "AutoloadAvitoStatus", - "AutoloadFieldType", - "AutoloadItemStatus", - "AutoloadItemStatusDetail", - "AutoloadReportStatus", - "ListingStatus", -) diff --git a/avito/ads/mappers.py b/avito/ads/mappers.py deleted file mode 100644 index 302d0bd..0000000 --- a/avito/ads/mappers.py +++ /dev/null @@ -1,505 +0,0 @@ -"""Мапперы JSON -> dataclass для пакета ads.""" - -from __future__ import annotations - -from collections.abc import Mapping -from datetime import datetime -from typing import cast - -from avito.ads.enums import ( - AdsActionStatus, - AutoloadAvitoStatus, - AutoloadFieldType, - AutoloadItemStatus, - AutoloadItemStatusDetail, - AutoloadReportStatus, - ListingStatus, -) -from avito.ads.models import ( - AccountSpendings, - AdsActionResult, - AdsListResult, - AutoloadFee, - AutoloadFeesResult, - AutoloadField, - AutoloadFieldsResult, - AutoloadProfileSettings, - AutoloadReportDetails, - AutoloadReportItem, - AutoloadReportItemsResult, - AutoloadReportsResult, - AutoloadReportSummary, - AutoloadTreeNode, - AutoloadTreeResult, - CallsStatsResult, - CallStats, - IdMappingResult, - ItemAnalyticsResult, - ItemStatsResult, - LegacyAutoloadReport, - Listing, - ListingStats, - SpendingRecord, - UpdatePriceResult, - UploadResult, - VasApplyResult, - VasPrice, - VasPricesResult, -) -from avito.core.enums import map_enum_or_unknown -from avito.core.exceptions import ResponseMappingError - -Payload = Mapping[str, object] - - -def _expect_mapping(payload: object) -> Payload: - if not isinstance(payload, Mapping): - raise ResponseMappingError("Ожидался JSON-объект.", payload=payload) - return cast(Payload, payload) - - -def _list(payload: Payload, *keys: str) -> list[Payload]: - for key in keys: - value = payload.get(key) - if isinstance(value, list): - return [item for item in value if isinstance(item, Mapping)] - return [] - - -def _mapping(payload: Payload, *keys: str) -> Payload | None: - for key in keys: - value = payload.get(key) - if isinstance(value, Mapping): - return cast(Payload, value) - return None - - -def _str(payload: Payload, *keys: str) -> str | None: - for key in keys: - value = payload.get(key) - if isinstance(value, str): - return value - return None - - -def _nested_str(payload: Payload, *keys: str) -> str | None: - value = _str(payload, *keys) - if value is not None: - return value - for key in keys: - nested = _mapping(payload, key) - if nested is None: - continue - nested_value = _str(nested, "value", "name", "title", "slug", "status") - if nested_value is not None: - return nested_value - return None - - -def _datetime(payload: Payload, *keys: str) -> datetime | None: - for key in keys: - value = payload.get(key) - if isinstance(value, str): - normalized = value.replace("Z", "+00:00") - try: - return datetime.fromisoformat(normalized) - except ValueError: - continue - return None - - -def _int(payload: Payload, *keys: str) -> int | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - continue - if isinstance(value, int): - return value - if isinstance(value, str) and value.isdigit(): - return int(value) - return None - - -def _float(payload: Payload, *keys: str) -> float | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - continue - if isinstance(value, (int, float)): - return float(value) - if isinstance(value, Mapping): - nested = cast(Payload, value) - nested_value = _float(nested, "value", "amount", "current", "price") - if nested_value is not None: - return nested_value - return None - - -def _bool(payload: Payload, *keys: str) -> bool | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - return value - return None - - -def _visibility(payload: Payload) -> bool | None: - visible = _bool(payload, "is_visible", "isVisible", "visible") - if visible is not None: - return visible - status = _nested_str(payload, "status") - if status is None: - return None - return status in {"active", "published", "visible"} - - -def map_ad_item(payload: object) -> Listing: - """Преобразует объявление в dataclass.""" - - data = _expect_mapping(payload) - source = _mapping(data, "item", "resource", "listing", "ad") or data - return Listing( - item_id=_int(source, "id", "item_id", "itemId", "itemID"), - user_id=_int(source, "user_id", "userId"), - title=_str(source, "title", "name"), - description=_str(source, "description", "descriptionHtml"), - status=map_enum_or_unknown( - _nested_str(source, "status", "state"), - ListingStatus, - enum_name="ads.listing_status", - ), - price=_float(source, "price"), - url=_str(source, "url", "link", "uri"), - category=_nested_str(source, "category", "categoryName"), - city=_nested_str(source, "city", "location"), - published_at=_datetime(source, "published_at", "publishedAt", "created_at", "createdAt"), - updated_at=_datetime(source, "updated_at", "updatedAt"), - is_moderated=_bool(source, "is_moderated", "isModerated", "moderated"), - is_visible=_visibility(source), - ) - - -def map_ads_list(payload: object) -> AdsListResult: - """Преобразует список объявлений в dataclass.""" - - data = _expect_mapping(payload) - items = [map_ad_item(item) for item in _list(data, "items", "result", "resources")] - meta = _mapping(data, "meta") - total = _int(data, "total", "count") - if total is None and meta is not None: - total = _int(meta, "total", "count") - return AdsListResult(items=items, total=total) - - -def map_update_price_result(payload: object) -> UpdatePriceResult: - """Преобразует результат обновления цены.""" - - data = _expect_mapping(payload) - return UpdatePriceResult( - item_id=_int(data, "item_id", "itemId", "id"), - price=_float(data, "price"), - status=map_enum_or_unknown( - _str(data, "status", "result"), - AdsActionStatus, - enum_name="ads.action_status", - ), - ) - - -def map_calls_stats(payload: object) -> CallsStatsResult: - """Преобразует статистику звонков.""" - - data = _expect_mapping(payload) - items = [ - CallStats( - item_id=_int(item, "item_id", "itemId", "id"), - calls=_int(item, "calls", "total"), - answered_calls=_int(item, "answered_calls", "answeredCalls"), - missed_calls=_int(item, "missed_calls", "missedCalls"), - ) - for item in _list(data, "items", "result", "stats") - ] - return CallsStatsResult(items=items) - - -def _map_item_stat(item: Payload) -> ListingStats: - return ListingStats( - item_id=_int(item, "item_id", "itemId", "id"), - views=_int(item, "views", "impressions"), - contacts=_int(item, "contacts", "contacts_total", "contactsTotal"), - favorites=_int(item, "favorites", "favorites_total", "favoritesTotal"), - ) - - -def map_item_stats(payload: object) -> ItemStatsResult: - """Преобразует статистику по списку объявлений.""" - - data = _expect_mapping(payload) - return ItemStatsResult( - items=[_map_item_stat(item) for item in _list(data, "items", "result", "stats")], - ) - - -def map_item_analytics(payload: object) -> ItemAnalyticsResult: - """Преобразует расширенную аналитику по объявлениям.""" - - data = _expect_mapping(payload) - return ItemAnalyticsResult( - items=[_map_item_stat(item) for item in _list(data, "items", "result", "stats")], - period=_str(data, "period"), - ) - - -def map_spendings(payload: object) -> AccountSpendings: - """Преобразует статистику расходов.""" - - data = _expect_mapping(payload) - items = [ - SpendingRecord( - item_id=_int(item, "item_id", "itemId", "id"), - amount=_float(item, "amount", "price", "cost"), - service=_str(item, "service", "serviceType", "type"), - ) - for item in _list(data, "items", "result", "spendings") - ] - total = _float(data, "total") - if total is None: - total = sum(item.amount for item in items if item.amount is not None) or None - return AccountSpendings(items=items, total=total) - - -def map_vas_prices(payload: object) -> VasPricesResult: - """Преобразует список доступных услуг продвижения.""" - - data = _expect_mapping(payload) - items = [ - VasPrice( - code=_str(item, "code", "slug", "type"), - title=_str(item, "title", "name"), - price=_float(item, "price", "amount"), - is_available=_bool(item, "is_available", "isAvailable", "available"), - ) - for item in _list(data, "items", "services", "result") - ] - return VasPricesResult(items=items) - - -def map_vas_apply_result(payload: object) -> VasApplyResult: - """Преобразует результат применения продвижения.""" - - data = _expect_mapping(payload) - return VasApplyResult( - success=bool(data.get("success", True)), - status=map_enum_or_unknown( - _str(data, "status", "result", "message"), - AdsActionStatus, - enum_name="ads.action_status", - ), - ) - - -def map_autoload_profile(payload: object) -> AutoloadProfileSettings: - """Преобразует профиль автозагрузки.""" - - data = _expect_mapping(payload) - return AutoloadProfileSettings( - user_id=_int(data, "user_id", "userId", "id"), - is_enabled=_bool(data, "is_enabled", "isEnabled", "enabled"), - upload_url=_str(data, "upload_url", "uploadUrl", "url"), - ) - - -def map_upload_result(payload: object) -> UploadResult: - """Преобразует результат загрузки файла.""" - - data = _expect_mapping(payload) - return UploadResult( - success=bool(data.get("success", True)), - report_id=_int(data, "report_id", "reportId", "id"), - ) - - -def map_autoload_fields(payload: object) -> AutoloadFieldsResult: - """Преобразует список полей категории.""" - - data = _expect_mapping(payload) - items = [ - AutoloadField( - slug=_str(item, "slug", "code", "id"), - title=_str(item, "title", "name"), - 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") - ] - return AutoloadFieldsResult(items=items) - - -def _map_tree_node(payload: Payload) -> AutoloadTreeNode: - return AutoloadTreeNode( - slug=_str(payload, "slug", "code", "id"), - title=_str(payload, "title", "name"), - children=[_map_tree_node(item) for item in _list(payload, "children", "items")], - ) - - -def map_autoload_tree(payload: object) -> AutoloadTreeResult: - """Преобразует дерево категорий.""" - - data = _expect_mapping(payload) - items = [_map_tree_node(item) for item in _list(data, "tree", "items", "result")] - return AutoloadTreeResult(items=items) - - -def map_id_mapping(payload: object) -> IdMappingResult: - """Преобразует ответ с сопоставлением идентификаторов.""" - - data = _expect_mapping(payload) - mappings: list[tuple[int | None, int | None]] = [] - for item in _list(data, "items", "result", "mappings"): - mappings.append((_int(item, "ad_id", "adId"), _int(item, "avito_id", "avitoId"))) - return IdMappingResult(mappings=mappings) - - -def _map_report_summary(item: Payload) -> AutoloadReportSummary: - return AutoloadReportSummary( - report_id=_int(item, "report_id", "reportId", "id"), - 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"), - ) - - -def map_autoload_reports(payload: object) -> AutoloadReportsResult: - """Преобразует список отчетов автозагрузки.""" - - data = _expect_mapping(payload) - return AutoloadReportsResult( - items=[_map_report_summary(item) for item in _list(data, "reports", "items", "result")], - total=_int(data, "total", "count"), - ) - - -def map_autoload_report_details(payload: object) -> AutoloadReportDetails: - """Преобразует детализацию отчета автозагрузки.""" - - data = _expect_mapping(payload) - return AutoloadReportDetails( - report_id=_int(data, "report_id", "reportId", "id"), - 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"), - warnings_count=_int(data, "warnings_count", "warningsCount"), - ) - - -def map_legacy_autoload_report(payload: object) -> LegacyAutoloadReport: - """Преобразует legacy-ответ отчета автозагрузки.""" - - data = _expect_mapping(payload) - return LegacyAutoloadReport( - report_id=_int(data, "report_id", "reportId", "id"), - status=map_enum_or_unknown( - _str(data, "status"), - AutoloadReportStatus, - enum_name="ads.autoload_report_status", - ), - ) - - -def map_autoload_report_items(payload: object) -> AutoloadReportItemsResult: - """Преобразует список объявлений отчета.""" - - data = _expect_mapping(payload) - items = [ - AutoloadReportItem( - item_id=_int(item, "item_id", "itemId", "id"), - avito_id=_int(item, "avito_id", "avitoId"), - status=map_enum_or_unknown( - _str(item, "status"), - AutoloadItemStatus, - enum_name="ads.autoload_item_status", - ), - title=_str(item, "title"), - status_detail=map_enum_or_unknown( - _str(item, "status_detail", "statusDetail"), - AutoloadItemStatusDetail, - enum_name="ads.autoload_item_status_detail", - ), - avito_status=map_enum_or_unknown( - _str(item, "avito_status", "avitoStatus"), - AutoloadAvitoStatus, - enum_name="ads.autoload_avito_status", - ), - ) - for item in _list(data, "items", "result") - ] - return AutoloadReportItemsResult(items=items, total=_int(data, "total", "count")) - - -def map_autoload_fees(payload: object) -> AutoloadFeesResult: - """Преобразует списания по объявлениям отчета.""" - - data = _expect_mapping(payload) - items = [ - AutoloadFee( - item_id=_int(item, "item_id", "itemId", "id"), - amount=_float(item, "amount", "price", "cost"), - service=_str(item, "service", "serviceType", "type"), - ) - for item in _list(data, "items", "result", "fees") - ] - total = _float(data, "total") - if total is None: - total = sum(item.amount for item in items if item.amount is not None) or None - return AutoloadFeesResult(items=items, total=total) - - -def map_action_result(payload: object) -> AdsActionResult: - """Преобразует ответ мутационной операции ads.""" - - if isinstance(payload, Mapping): - data = cast(Payload, payload) - return AdsActionResult( - success=bool(data.get("success", True)), - message=_str(data, "message"), - ) - return AdsActionResult(success=True) - - -__all__ = ( - "map_action_result", - "map_ad_item", - "map_ads_list", - "map_autoload_fees", - "map_autoload_fields", - "map_autoload_profile", - "map_autoload_report_details", - "map_autoload_report_items", - "map_autoload_reports", - "map_autoload_tree", - "map_calls_stats", - "map_id_mapping", - "map_item_analytics", - "map_item_stats", - "map_legacy_autoload_report", - "map_spendings", - "map_update_price_result", - "map_upload_result", - "map_vas_apply_result", - "map_vas_prices", -) diff --git a/avito/ads/models.py b/avito/ads/models.py index 5fdfc82..30a9504 100644 --- a/avito/ads/models.py +++ b/avito/ads/models.py @@ -2,21 +2,272 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass, field from datetime import datetime +from enum import Enum +from typing import cast -from avito.ads.enums import ( - AdsActionStatus, - AutoloadAvitoStatus, - AutoloadFieldType, - AutoloadItemStatus, - AutoloadItemStatusDetail, - AutoloadReportStatus, - ListingStatus, -) +from avito.core.enums import map_enum_or_unknown +from avito.core.exceptions import ResponseMappingError, ValidationError from avito.core.serialization import SerializableModel +class ListingStatus(str, Enum): + """Статус объявления.""" + + UNKNOWN = "__unknown__" + ACTIVE = "active" + REMOVED = "removed" + OLD = "old" + BLOCKED = "blocked" + REJECTED = "rejected" + NOT_FOUND = "not_found" + ANOTHER_USER = "another_user" + + +class AdsActionStatus(str, Enum): + """Статус мутационной операции ads.""" + + UNKNOWN = "__unknown__" + APPLIED = "applied" + UPDATED = "updated" + + +class AdAnalyticsGrouping(str, Enum): + """Группировка расширенной аналитики объявлений.""" + + DAY = "day" + WEEK = "week" + MONTH = "month" + ITEM = "item" + TOTALS = "totals" + + +class AdSpendingsGrouping(str, Enum): + """Группировка статистики расходов.""" + + DAY = "day" + WEEK = "week" + MONTH = "month" + + +AdAnalyticsGroupingInput = AdAnalyticsGrouping | str +AdSpendingsGroupingInput = AdSpendingsGrouping | str + + +class AutoloadFieldType(str, Enum): + """Тип поля автозагрузки.""" + + UNKNOWN = "__unknown__" + STRING = "string" + INTEGER = "integer" + FLOAT = "float" + INPUT = "input" + SELECT = "select" + CHECKBOX = "checkbox" + + +class AutoloadReportStatus(str, Enum): + """Статус отчета автозагрузки.""" + + UNKNOWN = "__unknown__" + DONE = "done" + PROCESSING = "processing" + SUCCESS = "success" + SUCCESS_WARNING = "success_warning" + ERROR = "error" + PROBLEM = "problem" + NOT_PUBLISH = "not_publish" + WILL_PUBLISH_LATER = "will_publish_later" + DUPLICATE = "duplicate" + WITHOUT_ID = "without_id" + DELETED = "deleted" + UPSTREAM_UNKNOWN = "unknown" + + +class AutoloadItemStatus(str, Enum): + """Статус объявления в отчете автозагрузки.""" + + UNKNOWN = "__unknown__" + SUCCESS = "success" + PROBLEM = "problem" + ERROR = "error" + NOT_PUBLISH = "not_publish" + WILL_PUBLISH_LATER = "will_publish_later" + DUPLICATE = "duplicate" + WITHOUT_ID = "without_id" + DELETED = "deleted" + UPSTREAM_UNKNOWN = "unknown" + + +class AutoloadItemStatusDetail(str, Enum): + """Подробный статус объявления в отчете автозагрузки.""" + + UNKNOWN = "__unknown__" + SUCCESS_ADDED = "success_added" + SUCCESS_ACTIVATED = "success_activated" + SUCCESS_ACTIVATED_UPDATED = "success_activated_updated" + SUCCESS_UPDATED = "success_updated" + SUCCESS_SKIPPED = "success_skipped" + PROBLEM_OBSOLETE = "problem_obsolete" + PROBLEM_PARAMS_CRITICAL = "problem_params_critical" + PROBLEM_PARAMS = "problem_params" + PROBLEM_PHONE = "problem_phone" + PROBLEM_IMAGES = "problem_images" + PROBLEM_VAS = "problem_vas" + PROBLEM_OTHER = "problem_other" + PROBLEM_SEVERAL = "problem_several" + ERROR_FEE = "error_fee" + ERROR_PARAMS = "error_params" + ERROR_PHONE = "error_phone" + ERROR_REJECTED = "error_rejected" + ERROR_BLOCKED = "error_blocked" + ERROR_DELETED = "error_deleted" + ERROR_OTHER = "error_other" + ERROR_SEVERAL = "error_several" + STOPPED_END_DATE_COMPLETE = "stopped_end_date_complete" + STOPPED_END_DATE_ERROR = "stopped_end_date_error" + DATE_IN_FUTURE = "date_in_future" + PUBLISH_LATER = "publish_later" + LINKER = "linker" + REMOVED_COMPLETE = "removed_complete" + REMOVED_ERROR = "removed_error" + NEED_SYNC = "need_sync" + DUPLICATE = "duplicate" + WITHOUT_ID = "without_id" + + +class AutoloadAvitoStatus(str, Enum): + """Статус объявления на Авито из отчета автозагрузки.""" + + UNKNOWN = "__unknown__" + ACTIVE = "active" + OLD = "old" + BLOCKED = "blocked" + REJECTED = "rejected" + ARCHIVED = "archived" + REMOVED = "removed" + + +_Payload = Mapping[str, object] + + +def _expect_mapping(payload: object) -> _Payload: + if not isinstance(payload, Mapping): + raise ResponseMappingError("Ожидался JSON-объект.", payload=payload) + return cast(_Payload, payload) + + +def _list(payload: _Payload, *keys: str) -> list[_Payload]: + for key in keys: + value = payload.get(key) + if isinstance(value, list): + return [item for item in value if isinstance(item, Mapping)] + return [] + + +def _mapping(payload: _Payload, *keys: str) -> _Payload | None: + for key in keys: + value = payload.get(key) + if isinstance(value, Mapping): + return cast(_Payload, value) + return None + + +def _str(payload: _Payload, *keys: str) -> str | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str): + return value + return None + + +def _nested_str(payload: _Payload, *keys: str) -> str | None: + value = _str(payload, *keys) + if value is not None: + return value + for key in keys: + nested = _mapping(payload, key) + if nested is None: + continue + nested_value = _str(nested, "value", "name", "title", "slug", "status") + if nested_value is not None: + return nested_value + return None + + +def _datetime(payload: _Payload, *keys: str) -> datetime | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str): + normalized = value.replace("Z", "+00:00") + try: + return datetime.fromisoformat(normalized) + except ValueError: + continue + return None + + +def _int(payload: _Payload, *keys: str) -> int | None: + for key in keys: + value = payload.get(key) + if isinstance(value, bool): + continue + if isinstance(value, int): + return value + if isinstance(value, str) and value.isdigit(): + return int(value) + return None + + +def _float(payload: _Payload, *keys: str) -> float | None: + for key in keys: + value = payload.get(key) + if isinstance(value, bool): + continue + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, Mapping): + nested = cast(_Payload, value) + nested_value = _float(nested, "value", "amount", "current", "price") + if nested_value is not None: + return nested_value + return None + + +def _bool(payload: _Payload, *keys: str) -> bool | None: + for key in keys: + value = payload.get(key) + if isinstance(value, bool): + return value + return None + + +def _enum_value[EnumT: Enum]( + enum_type: type[EnumT], + name: str, + value: EnumT | str, +) -> str: + if isinstance(value, enum_type): + return str(value.value) + try: + return str(enum_type(value).value) + except ValueError as exc: + allowed = ", ".join(str(item.value) for item in enum_type) + raise ValidationError(f"`{name}` должен быть одним из: {allowed}.") from exc + + +def _visibility(payload: _Payload) -> bool | None: + visible = _bool(payload, "is_visible", "isVisible", "visible") + if visible is not None: + return visible + status = _nested_str(payload, "status") + if status is None: + return None + return status in {"active", "published", "visible"} + + @dataclass(slots=True, frozen=True) class Listing(SerializableModel): """Объявление пользователя.""" @@ -35,6 +286,34 @@ class Listing(SerializableModel): is_moderated: bool | None = None is_visible: bool | None = None + @classmethod + def from_payload(cls, payload: object) -> Listing: + """Преобразует объявление в dataclass.""" + + data = _expect_mapping(payload) + source = _mapping(data, "item", "resource", "listing", "ad") or data + return cls( + item_id=_int(source, "id", "item_id", "itemId", "itemID"), + user_id=_int(source, "user_id", "userId"), + title=_str(source, "title", "name"), + description=_str(source, "description", "descriptionHtml"), + status=map_enum_or_unknown( + _nested_str(source, "status", "state"), + ListingStatus, + enum_name="ads.listing_status", + ), + price=_float(source, "price"), + url=_str(source, "url", "link", "uri"), + category=_nested_str(source, "category", "categoryName"), + city=_nested_str(source, "city", "location"), + published_at=_datetime( + source, "published_at", "publishedAt", "created_at", "createdAt" + ), + updated_at=_datetime(source, "updated_at", "updatedAt"), + is_moderated=_bool(source, "is_moderated", "isModerated", "moderated"), + is_visible=_visibility(source), + ) + @dataclass(slots=True, frozen=True) class AdsListResult(SerializableModel): @@ -43,6 +322,18 @@ class AdsListResult(SerializableModel): items: list[Listing] total: int | None = None + @classmethod + def from_payload(cls, payload: object) -> AdsListResult: + """Преобразует список объявлений в dataclass.""" + + data = _expect_mapping(payload) + items = [Listing.from_payload(item) for item in _list(data, "items", "result", "resources")] + meta = _mapping(data, "meta") + total = _int(data, "total", "count") + if total is None and meta is not None: + total = _int(meta, "total", "count") + return cls(items=items, total=total) + @dataclass(slots=True, frozen=True) class UpdatePriceRequest: @@ -64,14 +355,29 @@ class UpdatePriceResult(SerializableModel): price: float | None status: AdsActionStatus | None + @classmethod + def from_payload(cls, payload: object) -> UpdatePriceResult: + """Преобразует результат обновления цены.""" + + data = _expect_mapping(payload) + return cls( + item_id=_int(data, "item_id", "itemId", "id"), + price=_float(data, "price"), + status=map_enum_or_unknown( + _str(data, "status", "result"), + AdsActionStatus, + enum_name="ads.action_status", + ), + ) + @dataclass(slots=True, frozen=True) class CallsStatsRequest: """Запрос статистики звонков.""" item_ids: list[int] = field(default_factory=list) - date_from: str | None = None - date_to: str | None = None + date_from: str = "" + date_to: str = "" def to_payload(self) -> dict[str, object]: """Сериализует фильтр статистики звонков.""" @@ -103,14 +409,30 @@ class CallsStatsResult(SerializableModel): items: list[CallStats] + @classmethod + def from_payload(cls, payload: object) -> CallsStatsResult: + """Преобразует статистику звонков.""" + + data = _expect_mapping(payload) + items = [ + CallStats( + item_id=_int(item, "item_id", "itemId", "id"), + calls=_int(item, "calls", "total"), + answered_calls=_int(item, "answered_calls", "answeredCalls"), + missed_calls=_int(item, "missed_calls", "missedCalls"), + ) + for item in _list(data, "items", "result", "stats") + ] + return cls(items=items) + @dataclass(slots=True, frozen=True) class ItemStatsRequest: """Запрос статистики по объявлениям.""" item_ids: list[int] - date_from: str | None = None - date_to: str | None = None + date_from: str + date_to: str fields: list[str] = field(default_factory=list) def to_payload(self) -> dict[str, object]: @@ -128,6 +450,30 @@ def to_payload(self) -> dict[str, object]: } +@dataclass(slots=True, frozen=True) +class ItemAnalyticsRequest: + """Запрос расширенной аналитики по объявлениям.""" + + date_from: str + date_to: str + metrics: list[str] + grouping: AdAnalyticsGroupingInput + limit: int + offset: int + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос расширенной аналитики.""" + + return { + "dateFrom": self.date_from, + "dateTo": self.date_to, + "metrics": self.metrics, + "grouping": _enum_value(AdAnalyticsGrouping, "grouping", self.grouping), + "limit": self.limit, + "offset": self.offset, + } + + @dataclass(slots=True, frozen=True) class ListingStats(SerializableModel): """Статистические показатели объявления.""" @@ -138,12 +484,30 @@ class ListingStats(SerializableModel): favorites: int | None +def _map_item_stat(item: _Payload) -> ListingStats: + return ListingStats( + item_id=_int(item, "item_id", "itemId", "id"), + views=_int(item, "views", "impressions"), + contacts=_int(item, "contacts", "contacts_total", "contactsTotal"), + favorites=_int(item, "favorites", "favorites_total", "favoritesTotal"), + ) + + @dataclass(slots=True, frozen=True) class ItemStatsResult(SerializableModel): """Статистика по списку объявлений.""" items: list[ListingStats] + @classmethod + def from_payload(cls, payload: object) -> ItemStatsResult: + """Преобразует статистику по списку объявлений.""" + + data = _expect_mapping(payload) + return cls( + items=[_map_item_stat(item) for item in _list(data, "items", "result", "stats")], + ) + @dataclass(slots=True, frozen=True) class ItemAnalyticsResult(SerializableModel): @@ -152,6 +516,16 @@ class ItemAnalyticsResult(SerializableModel): items: list[ListingStats] period: str | None = None + @classmethod + def from_payload(cls, payload: object) -> ItemAnalyticsResult: + """Преобразует расширенную аналитику по объявлениям.""" + + data = _expect_mapping(payload) + return cls( + items=[_map_item_stat(item) for item in _list(data, "items", "result", "stats")], + period=_str(data, "period"), + ) + @dataclass(slots=True, frozen=True) class SpendingRecord(SerializableModel): @@ -169,6 +543,48 @@ class AccountSpendings(SerializableModel): items: list[SpendingRecord] total: float | None = None + @classmethod + def from_payload(cls, payload: object) -> AccountSpendings: + """Преобразует статистику расходов.""" + + data = _expect_mapping(payload) + items = [ + SpendingRecord( + item_id=_int(item, "item_id", "itemId", "id"), + amount=_float(item, "amount", "price", "cost"), + service=_str(item, "service", "serviceType", "type"), + ) + for item in _list(data, "items", "result", "spendings") + ] + total = _float(data, "total") + if total is None: + total = sum(item.amount for item in items if item.amount is not None) or None + return cls(items=items, total=total) + + +@dataclass(slots=True, frozen=True) +class SpendingsRequest: + """Запрос статистики расходов профиля.""" + + date_from: str + date_to: str + spending_types: list[str] + grouping: AdSpendingsGroupingInput + item_ids: list[int] = field(default_factory=list) + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос статистики расходов.""" + + payload: dict[str, object] = { + "dateFrom": self.date_from, + "dateTo": self.date_to, + "spendingTypes": self.spending_types, + "grouping": _enum_value(AdSpendingsGrouping, "grouping", self.grouping), + } + if self.item_ids: + payload["filter"] = {"itemIDs": self.item_ids} + return payload + @dataclass(slots=True, frozen=True) class VasPrice(SerializableModel): @@ -206,6 +622,26 @@ class VasPricesResult(SerializableModel): items: list[VasPrice] + @classmethod + def from_payload(cls, payload: object) -> VasPricesResult: + """Преобразует список доступных услуг продвижения.""" + + if isinstance(payload, list): + items_payload = [item for item in payload if isinstance(item, Mapping)] + else: + data = _expect_mapping(payload) + items_payload = _list(data, "items", "services", "result") + items = [ + VasPrice( + code=_str(item, "code", "slug", "type"), + title=_str(item, "title", "name"), + price=_float(item, "price", "amount"), + is_available=_bool(item, "is_available", "isAvailable", "available"), + ) + for item in items_payload + ] + return cls(items=items) + @dataclass(slots=True, frozen=True) class VasApplyResult(SerializableModel): @@ -214,17 +650,43 @@ class VasApplyResult(SerializableModel): success: bool status: AdsActionStatus | None = None + @classmethod + def from_payload(cls, payload: object) -> VasApplyResult: + """Преобразует результат применения продвижения.""" + + data = _expect_mapping(payload) + return cls( + success=bool(data.get("success", True)), + status=map_enum_or_unknown( + _str(data, "status", "result", "message"), + AdsActionStatus, + enum_name="ads.action_status", + ), + ) + @dataclass(slots=True, frozen=True) class ApplyVasRequest: """Запрос применения услуг продвижения.""" - codes: list[str] + vas_id: str def to_payload(self) -> dict[str, object]: """Сериализует запрос применения VAS.""" - return {"codes": self.codes} + return {"vas_id": self.vas_id} + + +@dataclass(slots=True, frozen=True) +class ApplyVasDirectRequest: + """Запрос применения услуг продвижения через v2 endpoint.""" + + slugs: list[str] + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос применения VAS v2.""" + + return {"slugs": self.slugs} @dataclass(slots=True, frozen=True) @@ -236,7 +698,7 @@ class ApplyVasPackageRequest: def to_payload(self) -> dict[str, object]: """Сериализует запрос применения пакета VAS.""" - return {"packageCode": self.package_code} + return {"package_id": self.package_code} @dataclass(slots=True, frozen=True) @@ -247,27 +709,52 @@ class AutoloadProfileSettings(SerializableModel): is_enabled: bool | None upload_url: str | None + @classmethod + def from_payload(cls, payload: object) -> AutoloadProfileSettings: + """Преобразует профиль автозагрузки.""" + + data = _expect_mapping(payload) + return cls( + user_id=_int(data, "user_id", "userId", "id"), + is_enabled=_bool(data, "is_enabled", "isEnabled", "enabled"), + upload_url=_str(data, "upload_url", "uploadUrl", "url"), + ) + @dataclass(slots=True, frozen=True) class AutoloadProfileUpdateRequest: """Запрос сохранения профиля автозагрузки.""" - is_enabled: bool | None = None - email: str | None = None - callback_url: str | None = None + is_enabled: bool + report_email: str + schedule_rate: int + schedule_weekdays: list[int] + schedule_time_slots: list[int] + upload_url: str | None = None + feed_name: str | None = None + feed_url: str | None = None def to_payload(self) -> dict[str, object]: """Сериализует изменения профиля.""" - return { - key: value - for key, value in { - "isEnabled": self.is_enabled, - "email": self.email, - "callbackUrl": self.callback_url, - }.items() - if value is not None + payload: dict[str, object] = { + "autoload_enabled": self.is_enabled, + "report_email": self.report_email, + "schedule": [ + { + "rate": self.schedule_rate, + "weekdays": list(self.schedule_weekdays), + "time_slots": list(self.schedule_time_slots), + } + ], } + if self.feed_url is not None: + payload["feeds_data"] = [ + {"feed_name": self.feed_name or "default", "feed_url": self.feed_url} + ] + if self.upload_url is not None: + payload["upload_url"] = self.upload_url + return payload @dataclass(slots=True, frozen=True) @@ -289,6 +776,16 @@ class UploadResult(SerializableModel): success: bool report_id: int | None = None + @classmethod + def from_payload(cls, payload: object) -> UploadResult: + """Преобразует результат загрузки файла.""" + + data = _expect_mapping(payload) + return cls( + success=bool(data.get("success", True)), + report_id=_int(data, "report_id", "reportId", "id"), + ) + @dataclass(slots=True, frozen=True) class AutoloadField(SerializableModel): @@ -306,6 +803,26 @@ class AutoloadFieldsResult(SerializableModel): items: list[AutoloadField] + @classmethod + def from_payload(cls, payload: object) -> AutoloadFieldsResult: + """Преобразует список полей категории.""" + + data = _expect_mapping(payload) + items = [ + AutoloadField( + slug=_str(item, "slug", "code", "id"), + title=_str(item, "title", "name"), + 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") + ] + return cls(items=items) + @dataclass(slots=True, frozen=True) class AutoloadTreeNode(SerializableModel): @@ -316,12 +833,28 @@ class AutoloadTreeNode(SerializableModel): children: list[AutoloadTreeNode] = field(default_factory=list) +def _map_tree_node(payload: _Payload) -> AutoloadTreeNode: + return AutoloadTreeNode( + slug=_str(payload, "slug", "code", "id"), + title=_str(payload, "title", "name"), + children=[_map_tree_node(item) for item in _list(payload, "children", "items")], + ) + + @dataclass(slots=True, frozen=True) class AutoloadTreeResult(SerializableModel): """Дерево категорий автозагрузки.""" items: list[AutoloadTreeNode] + @classmethod + def from_payload(cls, payload: object) -> AutoloadTreeResult: + """Преобразует дерево категорий.""" + + data = _expect_mapping(payload) + items = [_map_tree_node(item) for item in _list(data, "tree", "items", "result")] + return cls(items=items) + @dataclass(slots=True, frozen=True) class IdMappingResult(SerializableModel): @@ -329,6 +862,16 @@ class IdMappingResult(SerializableModel): mappings: list[tuple[int | None, int | None]] + @classmethod + def from_payload(cls, payload: object) -> IdMappingResult: + """Преобразует ответ с сопоставлением идентификаторов.""" + + data = _expect_mapping(payload) + mappings: list[tuple[int | None, int | None]] = [] + for item in _list(data, "items", "result", "mappings"): + mappings.append((_int(item, "ad_id", "adId"), _int(item, "avito_id", "avitoId"))) + return cls(mappings=mappings) + @dataclass(slots=True, frozen=True) class AutoloadReportSummary(SerializableModel): @@ -341,6 +884,20 @@ class AutoloadReportSummary(SerializableModel): processed_items: int | None +def _map_report_summary(item: _Payload) -> AutoloadReportSummary: + return AutoloadReportSummary( + report_id=_int(item, "report_id", "reportId", "id"), + 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"), + ) + + @dataclass(slots=True, frozen=True) class AutoloadReportsResult(SerializableModel): """Список отчетов автозагрузки.""" @@ -348,6 +905,16 @@ class AutoloadReportsResult(SerializableModel): items: list[AutoloadReportSummary] total: int | None = None + @classmethod + def from_payload(cls, payload: object) -> AutoloadReportsResult: + """Преобразует список отчетов автозагрузки.""" + + data = _expect_mapping(payload) + return cls( + items=[_map_report_summary(item) for item in _list(data, "reports", "items", "result")], + total=_int(data, "total", "count"), + ) + @dataclass(slots=True, frozen=True) class AutoloadReportItem(SerializableModel): @@ -368,6 +935,36 @@ class AutoloadReportItemsResult(SerializableModel): items: list[AutoloadReportItem] total: int | None = None + @classmethod + def from_payload(cls, payload: object) -> AutoloadReportItemsResult: + """Преобразует список объявлений отчета.""" + + data = _expect_mapping(payload) + items = [ + AutoloadReportItem( + item_id=_int(item, "item_id", "itemId", "id"), + avito_id=_int(item, "avito_id", "avitoId"), + status=map_enum_or_unknown( + _str(item, "status"), + AutoloadItemStatus, + enum_name="ads.autoload_item_status", + ), + title=_str(item, "title"), + status_detail=map_enum_or_unknown( + _str(item, "status_detail", "statusDetail"), + AutoloadItemStatusDetail, + enum_name="ads.autoload_item_status_detail", + ), + avito_status=map_enum_or_unknown( + _str(item, "avito_status", "avitoStatus"), + AutoloadAvitoStatus, + enum_name="ads.autoload_avito_status", + ), + ) + for item in _list(data, "items", "result") + ] + return cls(items=items, total=_int(data, "total", "count")) + @dataclass(slots=True, frozen=True) class AutoloadFee(SerializableModel): @@ -385,6 +982,24 @@ class AutoloadFeesResult(SerializableModel): items: list[AutoloadFee] total: float | None = None + @classmethod + def from_payload(cls, payload: object) -> AutoloadFeesResult: + """Преобразует списания по объявлениям отчета.""" + + data = _expect_mapping(payload) + items = [ + AutoloadFee( + item_id=_int(item, "item_id", "itemId", "id"), + amount=_float(item, "amount", "price", "cost"), + service=_str(item, "service", "serviceType", "type"), + ) + for item in _list(data, "items", "result", "fees") + ] + total = _float(data, "total") + if total is None: + total = sum(item.amount for item in items if item.amount is not None) or None + return cls(items=items, total=total) + @dataclass(slots=True, frozen=True) class AutoloadReportDetails(SerializableModel): @@ -397,6 +1012,24 @@ class AutoloadReportDetails(SerializableModel): errors_count: int | None warnings_count: int | None + @classmethod + def from_payload(cls, payload: object) -> AutoloadReportDetails: + """Преобразует детализацию отчета автозагрузки.""" + + data = _expect_mapping(payload) + return cls( + report_id=_int(data, "report_id", "reportId", "id"), + 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"), + warnings_count=_int(data, "warnings_count", "warningsCount"), + ) + @dataclass(slots=True, frozen=True) class LegacyAutoloadReport(SerializableModel): @@ -405,6 +1038,20 @@ class LegacyAutoloadReport(SerializableModel): report_id: int | None status: AutoloadReportStatus | None + @classmethod + def from_payload(cls, payload: object) -> LegacyAutoloadReport: + """Преобразует legacy-ответ отчета автозагрузки.""" + + data = _expect_mapping(payload) + return cls( + report_id=_int(data, "report_id", "reportId", "id"), + status=map_enum_or_unknown( + _str(data, "status"), + AutoloadReportStatus, + enum_name="ads.autoload_report_status", + ), + ) + @dataclass(slots=True, frozen=True) class AdsActionResult(SerializableModel): @@ -413,22 +1060,42 @@ class AdsActionResult(SerializableModel): success: bool message: str | None = None + @classmethod + def from_payload(cls, payload: object) -> AdsActionResult: + """Преобразует ответ мутационной операции ads.""" + + if isinstance(payload, Mapping): + data = cast(_Payload, payload) + return cls( + success=bool(data.get("success", True)), + message=_str(data, "message"), + ) + return cls(success=True) + __all__ = ( "AccountSpendings", + "AdAnalyticsGrouping", + "AdSpendingsGrouping", "AdsActionResult", + "AdsActionStatus", "AdsListResult", "ApplyVasPackageRequest", "ApplyVasRequest", + "AutoloadAvitoStatus", "AutoloadFee", "AutoloadFeesResult", "AutoloadField", + "AutoloadFieldType", "AutoloadFieldsResult", + "AutoloadItemStatus", + "AutoloadItemStatusDetail", "AutoloadProfileSettings", "AutoloadProfileUpdateRequest", "AutoloadReportDetails", "AutoloadReportItem", "AutoloadReportItemsResult", + "AutoloadReportStatus", "AutoloadReportSummary", "AutoloadReportsResult", "AutoloadTreeNode", @@ -443,6 +1110,7 @@ class AdsActionResult(SerializableModel): "LegacyAutoloadReport", "Listing", "ListingStats", + "ListingStatus", "SpendingRecord", "UpdatePriceRequest", "UpdatePriceResult", diff --git a/avito/ads/operations.py b/avito/ads/operations.py new file mode 100644 index 0000000..8969c1d --- /dev/null +++ b/avito/ads/operations.py @@ -0,0 +1,231 @@ +"""Operation specs for ads domain.""" + +from __future__ import annotations + +from avito.ads.models import ( + AccountSpendings, + AdsActionResult, + AdsListResult, + ApplyVasDirectRequest, + ApplyVasPackageRequest, + ApplyVasRequest, + AutoloadFeesResult, + AutoloadFieldsResult, + AutoloadProfileSettings, + AutoloadProfileUpdateRequest, + AutoloadReportDetails, + AutoloadReportItemsResult, + AutoloadReportsResult, + AutoloadTreeResult, + CallsStatsRequest, + CallsStatsResult, + IdMappingResult, + ItemAnalyticsRequest, + ItemAnalyticsResult, + ItemStatsRequest, + ItemStatsResult, + LegacyAutoloadReport, + Listing, + SpendingsRequest, + UpdatePriceRequest, + UpdatePriceResult, + UploadByUrlRequest, + UploadResult, + VasPricesRequest, + VasPricesResult, +) +from avito.core import OperationSpec +from avito.promotion.models import PromotionActionPayload + +GET_ITEM = OperationSpec( + name="ads.get_item", + method="GET", + path="/core/v1/accounts/{user_id}/items/{item_id}/", + response_model=Listing, +) +LIST_ITEMS = OperationSpec( + name="ads.list_items", + method="GET", + path="/core/v1/items", + response_model=AdsListResult, +) +UPDATE_PRICE = OperationSpec( + name="ads.update_price", + method="POST", + path="/core/v1/items/{item_id}/update_price", + request_model=UpdatePriceRequest, + response_model=UpdatePriceResult, + retry_mode="enabled", +) +GET_CALLS_STATS = OperationSpec( + name="ads.stats.calls", + method="POST", + path="/core/v1/accounts/{user_id}/calls/stats/", + request_model=CallsStatsRequest, + response_model=CallsStatsResult, + retry_mode="enabled", +) +GET_ITEM_STATS = OperationSpec( + name="ads.stats.items", + method="POST", + path="/stats/v1/accounts/{user_id}/items", + request_model=ItemStatsRequest, + response_model=ItemStatsResult, + retry_mode="enabled", +) +GET_ITEM_ANALYTICS = OperationSpec( + name="ads.stats.analytics", + method="POST", + path="/stats/v2/accounts/{user_id}/items", + request_model=ItemAnalyticsRequest, + response_model=ItemAnalyticsResult, + retry_mode="enabled", +) +GET_ACCOUNT_SPENDINGS = OperationSpec( + name="ads.stats.spendings", + method="POST", + path="/stats/v2/accounts/{user_id}/spendings", + request_model=SpendingsRequest, + response_model=AccountSpendings, + retry_mode="enabled", +) +GET_VAS_PRICES = OperationSpec( + name="ads.vas.prices", + method="POST", + path="/core/v1/accounts/{user_id}/vas/prices", + request_model=VasPricesRequest, + response_model=VasPricesResult, + retry_mode="enabled", +) +APPLY_ITEM_VAS: OperationSpec[object] = OperationSpec( + name="ads.vas.apply_item_vas", + method="PUT", + path="/core/v1/accounts/{user_id}/items/{item_id}/vas", + request_model=ApplyVasRequest, + response_model=PromotionActionPayload, + retry_mode="enabled", +) +APPLY_ITEM_VAS_PACKAGE: OperationSpec[object] = OperationSpec( + name="ads.vas.apply_item_vas_package", + method="PUT", + path="/core/v2/accounts/{user_id}/items/{item_id}/vas_packages", + request_model=ApplyVasPackageRequest, + response_model=PromotionActionPayload, + retry_mode="enabled", +) +APPLY_VAS_DIRECT: OperationSpec[object] = OperationSpec( + name="ads.vas.apply_direct", + method="PUT", + path="/core/v2/items/{item_id}/vas/", + request_model=ApplyVasDirectRequest, + response_model=PromotionActionPayload, + retry_mode="enabled", +) +GET_AUTOLOAD_PROFILE = OperationSpec( + name="ads.autoload.get_profile", + method="GET", + path="/autoload/v2/profile", + response_model=AutoloadProfileSettings, +) +SAVE_AUTOLOAD_PROFILE = OperationSpec( + name="ads.autoload.save_profile", + method="POST", + path="/autoload/v2/profile", + request_model=AutoloadProfileUpdateRequest, + response_model=AdsActionResult, + retry_mode="enabled", +) +UPLOAD_BY_URL = OperationSpec( + name="ads.autoload.upload_by_url", + method="POST", + path="/autoload/v1/upload", + request_model=UploadByUrlRequest, + response_model=UploadResult, + retry_mode="enabled", +) +GET_AUTOLOAD_NODE_FIELDS = OperationSpec( + name="ads.autoload.get_node_fields", + method="GET", + path="/autoload/v1/user-docs/node/{node_slug}/fields", + response_model=AutoloadFieldsResult, +) +GET_AUTOLOAD_TREE = OperationSpec( + name="ads.autoload.get_tree", + method="GET", + path="/autoload/v1/user-docs/tree", + response_model=AutoloadTreeResult, +) +GET_AD_IDS_BY_AVITO_IDS = OperationSpec( + name="ads.autoload.get_ad_ids_by_avito_ids", + method="GET", + path="/autoload/v2/items/ad_ids", + response_model=IdMappingResult, +) +GET_AVITO_IDS_BY_AD_IDS = OperationSpec( + name="ads.autoload.get_avito_ids_by_ad_ids", + method="GET", + path="/autoload/v2/items/avito_ids", + response_model=IdMappingResult, +) +LIST_AUTOLOAD_REPORTS = OperationSpec( + name="ads.autoload.list_reports", + method="GET", + path="/autoload/v2/reports", + response_model=AutoloadReportsResult, +) +GET_AUTOLOAD_ITEMS_INFO = OperationSpec( + name="ads.autoload.get_items_info", + method="GET", + path="/autoload/v2/reports/items", + response_model=AutoloadReportItemsResult, +) +GET_AUTOLOAD_REPORT = OperationSpec( + name="ads.autoload.get_report", + method="GET", + path="/autoload/v3/reports/{report_id}", + response_model=AutoloadReportDetails, +) +GET_AUTOLOAD_LAST_COMPLETED_REPORT = OperationSpec( + name="ads.autoload.get_last_completed_report", + method="GET", + path="/autoload/v3/reports/last_completed_report", + response_model=AutoloadReportDetails, +) +GET_AUTOLOAD_REPORT_ITEMS = OperationSpec( + name="ads.autoload.get_report_items", + method="GET", + path="/autoload/v2/reports/{report_id}/items", + response_model=AutoloadReportItemsResult, +) +GET_AUTOLOAD_REPORT_FEES = OperationSpec( + name="ads.autoload.get_report_fees", + method="GET", + path="/autoload/v2/reports/{report_id}/items/fees", + response_model=AutoloadFeesResult, +) +GET_ARCHIVE_PROFILE = OperationSpec( + name="ads.autoload_archive.get_profile", + method="GET", + path="/autoload/v1/profile", + response_model=AutoloadProfileSettings, +) +SAVE_ARCHIVE_PROFILE = OperationSpec( + name="ads.autoload_archive.save_profile", + method="POST", + path="/autoload/v1/profile", + request_model=AutoloadProfileUpdateRequest, + response_model=AdsActionResult, + retry_mode="enabled", +) +GET_ARCHIVE_LAST_COMPLETED_REPORT = OperationSpec( + name="ads.autoload_archive.get_last_completed_report", + method="GET", + path="/autoload/v2/reports/last_completed_report", + response_model=LegacyAutoloadReport, +) +GET_ARCHIVE_REPORT = OperationSpec( + name="ads.autoload_archive.get_report", + method="GET", + path="/autoload/v2/reports/{report_id}", + response_model=LegacyAutoloadReport, +) diff --git a/avito/auth/enums.py b/avito/auth/enums.py deleted file mode 100644 index b80a6ce..0000000 --- a/avito/auth/enums.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Строковые константы grant type для OAuth flow.""" - -CLIENT_CREDENTIALS_GRANT = "client_credentials" -REFRESH_TOKEN_GRANT = "refresh_token" - -__all__ = ("CLIENT_CREDENTIALS_GRANT", "REFRESH_TOKEN_GRANT") diff --git a/avito/auth/mappers.py b/avito/auth/mappers.py deleted file mode 100644 index fbd034e..0000000 --- a/avito/auth/mappers.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Мапперы low-level auth payload -> dataclass.""" - -from __future__ import annotations - -from datetime import UTC, datetime, timedelta - -from avito.auth.models import AccessToken, TokenResponse -from avito.core.exceptions import ResponseMappingError - - -def map_token_response(payload: object, *, now: datetime | None = None) -> TokenResponse: - """Преобразует OAuth payload в типизированную модель токена.""" - - if not isinstance(payload, dict): - raise ResponseMappingError("OAuth-ответ должен быть JSON-объектом.", payload=payload) - - access_token = payload.get("access_token") - if not isinstance(access_token, str) or not access_token: - raise ResponseMappingError("В OAuth-ответе отсутствует `access_token`.", payload=payload) - - raw_expires_in = payload.get("expires_in", 0) - if not isinstance(raw_expires_in, int): - raise ResponseMappingError("Поле `expires_in` должно быть целым числом.", payload=payload) - - refresh_token = payload.get("refresh_token") - if refresh_token is not None and not isinstance(refresh_token, str): - raise ResponseMappingError("Поле `refresh_token` должно быть строкой.", payload=payload) - - token_type = payload.get("token_type", "Bearer") - if not isinstance(token_type, str): - raise ResponseMappingError("Поле `token_type` должно быть строкой.", payload=payload) - - issued_at = now or datetime.now(UTC) - return TokenResponse( - access_token=AccessToken( - value=access_token, - expires_at=issued_at + timedelta(seconds=raw_expires_in), - token_type=token_type, - ), - refresh_token=refresh_token, - scope=payload.get("scope") if isinstance(payload.get("scope"), str) else None, - ) - - -__all__ = ("map_token_response",) diff --git a/avito/auth/provider.py b/avito/auth/provider.py index bb05cac..cbf946a 100644 --- a/avito/auth/provider.py +++ b/avito/auth/provider.py @@ -3,13 +3,11 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from typing import Protocol import httpx -from avito.auth.enums import CLIENT_CREDENTIALS_GRANT, REFRESH_TOKEN_GRANT -from avito.auth.mappers import map_token_response from avito.auth.models import ( AccessToken, ClientCredentialsRequest, @@ -17,12 +15,55 @@ TokenResponse, ) from avito.auth.settings import AuthSettings -from avito.core.exceptions import AuthenticationError, ConfigurationError +from avito.config import AvitoSettings +from avito.core.exceptions import ( + AuthenticationError, + AvitoError, + ConfigurationError, + ResponseMappingError, +) from avito.core.swagger import swagger_operation +from avito.core.transport import Transport +from avito.core.types import RequestContext + +CLIENT_CREDENTIALS_GRANT = "client_credentials" +REFRESH_TOKEN_GRANT = "refresh_token" _UNSET = object() +def _map_token_response(payload: object, *, now: datetime | None = None) -> TokenResponse: + if not isinstance(payload, dict): + raise ResponseMappingError("OAuth-ответ должен быть JSON-объектом.", payload=payload) + + access_token = payload.get("access_token") + if not isinstance(access_token, str) or not access_token: + raise ResponseMappingError("В OAuth-ответе отсутствует `access_token`.", payload=payload) + + raw_expires_in = payload.get("expires_in", 0) + if not isinstance(raw_expires_in, int | float) or isinstance(raw_expires_in, bool): + raise ResponseMappingError("Поле `expires_in` должно быть числом.", payload=payload) + + refresh_token = payload.get("refresh_token") + if refresh_token is not None and not isinstance(refresh_token, str): + raise ResponseMappingError("Поле `refresh_token` должно быть строкой.", payload=payload) + + token_type = payload.get("token_type", "Bearer") + if not isinstance(token_type, str): + raise ResponseMappingError("Поле `token_type` должно быть строкой.", payload=payload) + + issued_at = now or datetime.now(UTC) + return TokenResponse( + access_token=AccessToken( + value=access_token, + expires_at=issued_at + timedelta(seconds=raw_expires_in), + token_type=token_type, + ), + refresh_token=refresh_token, + scope=payload.get("scope") if isinstance(payload.get("scope"), str) else None, + ) + + class TokenFetcher(Protocol): """Контракт получения нового access token из внешнего источника.""" @@ -79,13 +120,15 @@ def get_autoteka_access_token(self) -> str: token = self._autoteka_access_token now = datetime.now(UTC) if token is None or token.is_expired(now): - token_response = self._get_autoteka_token_client().request_autoteka_client_credentials_token( - ClientCredentialsRequest( - client_id=self.settings.autoteka_client_id or self.settings.client_id or "", - client_secret=self.settings.autoteka_client_secret - or self.settings.client_secret - or "", - scope=self.settings.autoteka_scope, + token_response = ( + self._get_autoteka_token_client().request_autoteka_client_credentials_token( + ClientCredentialsRequest( + client_id=self.settings.autoteka_client_id or self.settings.client_id or "", + client_secret=self.settings.autoteka_client_secret + or self.settings.client_secret + or "", + scope=self.settings.autoteka_scope, + ) ) ) self._update_tokens(autoteka_access_token=token_response.access_token) @@ -198,6 +241,7 @@ class TokenClient: settings: AuthSettings token_url: str | None = None client: httpx.Client | None = None + sdk_settings: AvitoSettings | None = None def close(self) -> None: """Закрывает выделенный HTTP-клиент, если он был передан снаружи.""" @@ -256,62 +300,54 @@ def request_refresh_token(self, request: RefreshTokenRequest) -> TokenResponse: return self._request_token(payload) def _request_token(self, payload: dict[str, str]) -> TokenResponse: - client = self.client or httpx.Client() - owns_client = self.client is None + transport = self._build_transport() try: - response = client.post( + response = transport.request( + "POST", self.token_url or self.settings.token_url, + context=RequestContext("auth.oauth_token", requires_auth=False), data=payload, headers={"Accept": "application/json"}, ) - except httpx.HTTPError as exc: - raise AuthenticationError(f"Ошибка OAuth transport: {exc}") from exc - finally: - if owns_client: - client.close() - - if response.is_error: + except AuthenticationError: + raise + except AvitoError as exc: raise AuthenticationError( - self._extract_error_message(response), - status_code=response.status_code, - error_code=self._extract_error_code(response), - payload=self._safe_payload(response), - headers=dict(response.headers), - ) + exc.message, + status_code=exc.status_code, + error_code=exc.error_code, + operation=exc.operation, + attempt=exc.attempt, + method=exc.method, + endpoint=exc.endpoint, + details=exc.details, + retry_after=exc.retry_after, + request_id=exc.request_id, + metadata=exc.metadata, + payload=exc.payload, + headers=exc.headers, + ) from exc + finally: + if self.client is None: + transport.close() try: payload_object = response.json() except ValueError as exc: raise AuthenticationError( - "OAuth endpoint вернул невалидный JSON.", + "OAuth-сервер вернул некорректный JSON.", status_code=response.status_code, payload=response.text, headers=dict(response.headers), ) from exc - return map_token_response(payload_object) + return _map_token_response(payload_object) - def _safe_payload(self, response: httpx.Response) -> object: - try: - return response.json() - except ValueError: - return response.text - - def _extract_error_message(self, response: httpx.Response) -> str: - payload = self._safe_payload(response) - if isinstance(payload, dict): - for key in ("message", "error_description", "error", "detail"): - value = payload.get(key) - if isinstance(value, str) and value: - return value - return f"Ошибка OAuth endpoint: HTTP {response.status_code}" - - def _extract_error_code(self, response: httpx.Response) -> str | None: - payload = self._safe_payload(response) - if isinstance(payload, dict): - value = payload.get("error") or payload.get("code") - if isinstance(value, str): - return value - return None + def _build_transport(self) -> Transport: + return Transport( + self.sdk_settings or AvitoSettings(auth=self.settings), + auth_provider=None, + client=self.client, + ) @dataclass(slots=True, frozen=True) @@ -322,6 +358,7 @@ class AlternateTokenClient: settings: AuthSettings client: httpx.Client | None = None + sdk_settings: AvitoSettings | None = None def close(self) -> None: """Закрывает выделенный HTTP-клиент альтернативного token flow.""" @@ -346,6 +383,7 @@ def request_client_credentials_token( self.settings, token_url=self.settings.alternate_token_url, client=self.client, + sdk_settings=self.sdk_settings, ).request_client_credentials_token(request) @swagger_operation( @@ -362,6 +400,7 @@ def request_refresh_token(self, request: RefreshTokenRequest) -> TokenResponse: self.settings, token_url=self.settings.alternate_token_url, client=self.client, + sdk_settings=self.sdk_settings, ).request_refresh_token(request) diff --git a/avito/auth/settings.py b/avito/auth/settings.py index cf57237..22e2b22 100644 --- a/avito/auth/settings.py +++ b/avito/auth/settings.py @@ -2,11 +2,14 @@ from __future__ import annotations +import os +import warnings +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path from typing import ClassVar -from avito._env import resolve_env_aliases +from avito._env import read_dotenv, resolve_env_aliases from avito.core.exceptions import ConfigurationError @@ -16,7 +19,7 @@ class AuthSettings: ENV_ALIASES: ClassVar[dict[str, tuple[str, ...]]] = { "client_id": ("AVITO_CLIENT_ID",), - "client_secret": ("AVITO_CLIENT_SECRET", "AVITO_SECRET"), + "client_secret": ("AVITO_CLIENT_SECRET",), "scope": ("AVITO_SCOPE",), "refresh_token": ("AVITO_REFRESH_TOKEN",), "token_url": ("AVITO_TOKEN_URL",), @@ -26,6 +29,9 @@ class AuthSettings: "autoteka_client_secret": ("AVITO_AUTOTEKA_CLIENT_SECRET",), "autoteka_scope": ("AVITO_AUTOTEKA_SCOPE",), } + DEPRECATED_ENV_ALIASES: ClassVar[dict[str, tuple[str, ...]]] = { + "client_secret": ("AVITO_SECRET",), + } client_id: str | None = None client_secret: str | None = None @@ -42,15 +48,43 @@ class AuthSettings: def from_env(cls, *, env_file: str | Path | None = ".env") -> AuthSettings: """Загружает auth-настройки из процесса и optional `.env` файла.""" - resolved_values = resolve_env_aliases(cls.ENV_ALIASES, env_file=env_file) + aliases = cls._env_aliases_with_deprecated() + resolved_values = resolve_env_aliases(aliases, env_file=env_file) + if cls._uses_deprecated_client_secret_alias(env_file=env_file): + warnings.warn( + "`AVITO_SECRET` устарел; используйте `AVITO_CLIENT_SECRET`.", + DeprecationWarning, + stacklevel=2, + ) return cls(**resolved_values).validate_required() @classmethod def supported_env_vars(cls) -> dict[str, tuple[str, ...]]: - """Возвращает документированный набор env-переменных и alias-имен.""" + """Возвращает документированный набор env-переменных.""" return dict(cls.ENV_ALIASES) + @classmethod + def _env_aliases_with_deprecated(cls) -> dict[str, tuple[str, ...]]: + aliases = dict(cls.ENV_ALIASES) + for field_name, deprecated_aliases in cls.DEPRECATED_ENV_ALIASES.items(): + aliases[field_name] = aliases.get(field_name, ()) + deprecated_aliases + return aliases + + @classmethod + def _uses_deprecated_client_secret_alias( + cls, *, env_file: str | Path | None + ) -> bool: + canonical_aliases = cls.ENV_ALIASES["client_secret"] + deprecated_aliases = cls.DEPRECATED_ENV_ALIASES["client_secret"] + file_values = read_dotenv(env_file) + for source in (os.environ, file_values): + if _has_env_value(source, canonical_aliases): + return False + if _has_env_value(source, deprecated_aliases): + return True + return False + def validate_required(self) -> AuthSettings: """Проверяет обязательные поля OAuth-конфигурации.""" @@ -68,4 +102,8 @@ def validate_required(self) -> AuthSettings: return self +def _has_env_value(source: Mapping[str, str], aliases: tuple[str, ...]) -> bool: + return any(source.get(alias) not in {None, ""} for alias in aliases) + + __all__ = ("AuthSettings",) diff --git a/avito/autoteka/__init__.py b/avito/autoteka/__init__.py index ef49b3b..9289dfb 100644 --- a/avito/autoteka/__init__.py +++ b/avito/autoteka/__init__.py @@ -7,7 +7,6 @@ AutotekaValuation, AutotekaVehicle, ) -from avito.autoteka.enums import AutotekaStatus from avito.autoteka.models import ( AutotekaLeadEvent, AutotekaLeadsResult, @@ -17,6 +16,7 @@ AutotekaReportsResult, AutotekaScoringInfo, AutotekaSpecificationInfo, + AutotekaStatus, AutotekaTeaserInfo, AutotekaValuationInfo, CatalogField, @@ -29,7 +29,6 @@ MonitoringBucketRequest, MonitoringBucketResult, MonitoringEvent, - MonitoringEventsQuery, MonitoringEventsResult, MonitoringInvalidVehicle, PlateNumberRequest, @@ -68,7 +67,6 @@ "MonitoringBucketRequest", "MonitoringBucketResult", "MonitoringEvent", - "MonitoringEventsQuery", "MonitoringEventsResult", "MonitoringInvalidVehicle", "PlateNumberRequest", diff --git a/avito/autoteka/client.py b/avito/autoteka/client.py deleted file mode 100644 index 282f145..0000000 --- a/avito/autoteka/client.py +++ /dev/null @@ -1,435 +0,0 @@ -"""Внутренние section clients для пакета autoteka.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from avito.autoteka.mappers import ( - map_catalogs_resolve, - map_leads, - map_monitoring_bucket, - map_monitoring_events, - map_package, - map_preview, - map_report, - map_reports, - map_scoring, - map_specification, - map_teaser, - map_valuation, -) -from avito.autoteka.models import ( - AutotekaLeadsResult, - AutotekaPackageInfo, - AutotekaPreviewInfo, - AutotekaReportInfo, - AutotekaReportsResult, - AutotekaScoringInfo, - AutotekaSpecificationInfo, - AutotekaTeaserInfo, - AutotekaValuationInfo, - CatalogResolveRequest, - CatalogResolveResult, - ExternalItemPreviewRequest, - ItemIdRequest, - LeadsRequest, - MonitoringBucketRequest, - MonitoringBucketResult, - MonitoringEventsQuery, - MonitoringEventsResult, - PlateNumberRequest, - PreviewReportRequest, - RegNumberRequest, - TeaserCreateRequest, - ValuationBySpecificationRequest, - VehicleIdRequest, - VinRequest, -) -from avito.core import RequestContext, Transport - - -@dataclass(slots=True, frozen=True) -class AutotekaBaseClient: - """Базовый клиент Автотеки с отдельным access token.""" - - transport: Transport - - def _context(self, operation_name: str, *, allow_retry: bool = False) -> RequestContext: - 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()}" - return RequestContext( - operation_name, - allow_retry=allow_retry, - requires_auth=False, - headers=headers, - ) - - -@dataclass(slots=True, frozen=True) -class CatalogClient(AutotekaBaseClient): - """Выполняет HTTP-операции автокаталога.""" - - 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), - mapper=map_catalogs_resolve, - json_body=CatalogResolveRequest(brand_id=brand_id).to_payload(), - ) - - -@dataclass(slots=True, frozen=True) -class LeadsClient(AutotekaBaseClient): - """Выполняет HTTP-операции сервиса Сигнал.""" - - 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), - mapper=map_leads, - json_body=LeadsRequest(limit=limit).to_payload(), - ) - - -@dataclass(slots=True, frozen=True) -class PreviewClient(AutotekaBaseClient): - """Выполняет HTTP-операции превью автомобиля.""" - - 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", - VinRequest(vin=vin), - idempotency_key=idempotency_key, - ) - - 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", - ExternalItemPreviewRequest(item_id=item_id, site=site), - idempotency_key=idempotency_key, - ) - - 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", - ItemIdRequest(item_id=item_id), - idempotency_key=idempotency_key, - ) - - 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", - RegNumberRequest(reg_number=reg_number), - idempotency_key=idempotency_key, - ) - - def get_preview(self, *, preview_id: int | str) -> AutotekaPreviewInfo: - return self.transport.request_public_model( - "GET", - f"/autoteka/v1/previews/{preview_id}", - context=self._context("autoteka.preview.get"), - mapper=map_preview, - ) - - def _post_preview( - self, - path: str, - operation: str, - request: VinRequest | ExternalItemPreviewRequest | ItemIdRequest | RegNumberRequest, - idempotency_key: str | None = None, - ) -> AutotekaPreviewInfo: - return self.transport.request_public_model( - "POST", - path, - context=self._context(operation, allow_retry=idempotency_key is not None), - mapper=map_preview, - json_body=request.to_payload(), - idempotency_key=idempotency_key, - ) - - -@dataclass(slots=True, frozen=True) -class ReportClient(AutotekaBaseClient): - """Выполняет HTTP-операции отчетов Автотеки.""" - - def get_active_package(self) -> AutotekaPackageInfo: - return self.transport.request_public_model( - "GET", - "/autoteka/v1/packages/active_package", - context=self._context("autoteka.report.get_active_package"), - mapper=map_package, - ) - - 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, *, 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", - VehicleIdRequest(vehicle_id=vehicle_id), - idempotency_key=idempotency_key, - ) - - def list_reports(self) -> AutotekaReportsResult: - return self.transport.request_public_model( - "GET", - "/autoteka/v1/reports/list/", - context=self._context("autoteka.report.list"), - mapper=map_reports, - ) - - def get_report(self, *, report_id: int | str) -> AutotekaReportInfo: - return self.transport.request_public_model( - "GET", - f"/autoteka/v1/reports/{report_id}", - context=self._context("autoteka.report.get"), - mapper=map_report, - ) - - 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", - RegNumberRequest(reg_number=reg_number), - idempotency_key=idempotency_key, - ) - - 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", - VinRequest(vin=vin), - idempotency_key=idempotency_key, - ) - - def _post_report( - self, - path: str, - operation: str, - request: PreviewReportRequest | VehicleIdRequest | RegNumberRequest | VinRequest, - idempotency_key: str | None = None, - ) -> AutotekaReportInfo: - return self.transport.request_public_model( - "POST", - path, - context=self._context(operation, allow_retry=idempotency_key is not None), - mapper=map_report, - json_body=request.to_payload(), - idempotency_key=idempotency_key, - ) - - -@dataclass(slots=True, frozen=True) -class MonitoringClient(AutotekaBaseClient): - """Выполняет HTTP-операции мониторинга.""" - - 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", - MonitoringBucketRequest(vehicles=vehicles), - idempotency_key=idempotency_key, - ) - - 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=idempotency_key is not None, - ), - mapper=map_monitoring_bucket, - idempotency_key=idempotency_key, - ) - - 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", - MonitoringBucketRequest(vehicles=vehicles), - idempotency_key=idempotency_key, - ) - - def get_reg_actions( - self, *, query: MonitoringEventsQuery | None = None - ) -> MonitoringEventsResult: - 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, - ) - - def _post_bucket( - self, - path: str, - operation: str, - request: MonitoringBucketRequest, - idempotency_key: str | None = None, - ) -> MonitoringBucketResult: - return self.transport.request_public_model( - "POST", - path, - context=self._context(operation, allow_retry=idempotency_key is not None), - mapper=map_monitoring_bucket, - json_body=request.to_payload(), - idempotency_key=idempotency_key, - ) - - -@dataclass(slots=True, frozen=True) -class ScoringClient(AutotekaBaseClient): - """Выполняет HTTP-операции скоринга рисков.""" - - 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=idempotency_key is not None, - ), - mapper=map_scoring, - json_body=VehicleIdRequest(vehicle_id=vehicle_id).to_payload(), - idempotency_key=idempotency_key, - ) - - def get_by_id(self, *, scoring_id: int | str) -> AutotekaScoringInfo: - return self.transport.request_public_model( - "GET", - f"/autoteka/v1/scoring/{scoring_id}", - context=self._context("autoteka.scoring.get_by_id"), - mapper=map_scoring, - ) - - -@dataclass(slots=True, frozen=True) -class SpecificationsClient(AutotekaBaseClient): - """Выполняет HTTP-операции спецификаций автомобиля.""" - - 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", - PlateNumberRequest(plate_number=plate_number), - idempotency_key=idempotency_key, - ) - - 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", - VehicleIdRequest(vehicle_id=vehicle_id), - idempotency_key=idempotency_key, - ) - - def get_by_id(self, *, specification_id: int | str) -> AutotekaSpecificationInfo: - 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, - ) - - def _post_specification( - self, - path: str, - operation: str, - request: PlateNumberRequest | VehicleIdRequest, - idempotency_key: str | None = None, - ) -> AutotekaSpecificationInfo: - return self.transport.request_public_model( - "POST", - path, - context=self._context(operation, allow_retry=idempotency_key is not None), - mapper=map_specification, - json_body=request.to_payload(), - idempotency_key=idempotency_key, - ) - - -@dataclass(slots=True, frozen=True) -class TeaserClient(AutotekaBaseClient): - """Выполняет HTTP-операции тизеров.""" - - 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=idempotency_key is not None), - mapper=map_teaser, - json_body=TeaserCreateRequest(vehicle_id=vehicle_id).to_payload(), - idempotency_key=idempotency_key, - ) - - def get(self, *, teaser_id: int | str) -> AutotekaTeaserInfo: - return self.transport.request_public_model( - "GET", - f"/autoteka/v1/teasers/{teaser_id}", - context=self._context("autoteka.teaser.get"), - mapper=map_teaser, - ) - - -@dataclass(slots=True, frozen=True) -class ValuationClient(AutotekaBaseClient): - """Выполняет HTTP-операции оценки стоимости.""" - - def get_by_specification( - self, request: ValuationBySpecificationRequest - ) -> AutotekaValuationInfo: - 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(), - ) diff --git a/avito/autoteka/domain.py b/avito/autoteka/domain.py index 04515e9..825f9f3 100644 --- a/avito/autoteka/domain.py +++ b/avito/autoteka/domain.py @@ -4,17 +4,6 @@ from dataclasses import dataclass -from avito.autoteka.client import ( - CatalogClient, - LeadsClient, - MonitoringClient, - PreviewClient, - ReportClient, - ScoringClient, - SpecificationsClient, - TeaserClient, - ValuationClient, -) from avito.autoteka.models import ( AutotekaLeadsResult, AutotekaPackageInfo, @@ -25,15 +14,62 @@ AutotekaSpecificationInfo, AutotekaTeaserInfo, AutotekaValuationInfo, + CatalogResolveRequest, CatalogResolveResult, + ExternalItemPreviewRequest, + ItemIdRequest, + LeadsRequest, + MonitoringBucketRequest, MonitoringBucketResult, MonitoringEventsQuery, MonitoringEventsResult, + PlateNumberRequest, + PreviewReportRequest, + RegNumberRequest, + TeaserCreateRequest, ValuationBySpecificationRequest, + VehicleIdRequest, + VinRequest, +) +from avito.autoteka.operations import ( + ADD_MONITORING_BUCKET, + CATALOG_RESOLVE, + CREATE_PREVIEW_BY_EXTERNAL_ITEM, + CREATE_PREVIEW_BY_ITEM_ID, + CREATE_PREVIEW_BY_REG_NUMBER, + CREATE_PREVIEW_BY_VIN, + CREATE_REPORT, + CREATE_REPORT_BY_VEHICLE_ID, + CREATE_SCORING_BY_VEHICLE_ID, + CREATE_SPECIFICATION_BY_PLATE_NUMBER, + CREATE_SPECIFICATION_BY_VEHICLE_ID, + CREATE_SYNC_REPORT_BY_REG_NUMBER, + CREATE_SYNC_REPORT_BY_VIN, + CREATE_TEASER, + DELETE_MONITORING_BUCKET, + GET_ACTIVE_PACKAGE, + GET_LEADS, + GET_MONITORING_REG_ACTIONS, + GET_PREVIEW, + GET_REPORT, + GET_SCORING_BY_ID, + GET_SPECIFICATION_BY_ID, + GET_TEASER, + GET_VALUATION_BY_SPECIFICATION, + LIST_REPORTS, + REMOVE_MONITORING_BUCKET, ) -from avito.core import ValidationError +from avito.core import ApiTimeouts, RetryOverride, ValidationError from avito.core.domain import DomainObject from avito.core.swagger import swagger_operation +from avito.core.transport import Transport + + +def _autoteka_headers(transport: Transport) -> dict[str, str]: + auth_provider = transport.auth_provider + if auth_provider is None: + return {} + return {"Authorization": f"Bearer {auth_provider.get_autoteka_access_token()}"} @dataclass(slots=True, frozen=True) @@ -52,32 +88,83 @@ class AutotekaVehicle(DomainObject): "/autoteka/v1/catalogs/resolve", spec="Автотека.json", operation_id="catalogsResolve", - method_args={"brand_id": "body.fields_value_ids"}, + method_args={"brand_id": "body.fieldsValueIds[].valueId"}, ) - def resolve_catalog(self, *, brand_id: int) -> CatalogResolveResult: + def resolve_catalog( + self, + *, + brand_id: int, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CatalogResolveResult: """Актуализирует параметры автокаталога. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + brand_id: идентифицирует марку автомобиля в каталоге. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CatalogResolveResult` с актуализированными параметрами каталога. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return CatalogClient(self.transport).resolve_catalog(brand_id=brand_id) + return self._execute( + CATALOG_RESOLVE, + request=CatalogResolveRequest(brand_id=brand_id), + headers=_autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) @swagger_operation( "POST", "/autoteka/v1/get-leads", spec="Автотека.json", operation_id="getLeads", - method_args={"limit": "body.limit"}, + method_args={"subscription_id": "body.subscriptionId", "limit": "body.limit"}, ) - def get_leads(self, *, limit: int) -> AutotekaLeadsResult: - """Выполняет публичную операцию `AutotekaVehicle.get_leads` и возвращает типизированную SDK-модель. - - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + def get_leads( + self, + *, + subscription_id: int, + limit: int, + last_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaLeadsResult: + """Возвращает leads для автомобилей Автотеки. + + Аргументы: + subscription_id: идентифицирует подписку Сигнала. + limit: ограничивает размер возвращаемой выборки. + last_id: задает последний прочитанный идентификатор для постраничной выдачи. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaLeadsResult` с типизированными данными ответа API. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return LeadsClient(self.transport).get_leads(limit=limit) + return self._execute( + GET_LEADS, + request=LeadsRequest(subscription_id=subscription_id, limit=limit, last_id=last_id), + headers=_autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) @swagger_operation( "POST", @@ -87,20 +174,39 @@ def get_leads(self, *, limit: int) -> AutotekaLeadsResult: method_args={"vin": "body.vin"}, ) def create_preview_by_vin( - self, *, vin: str, idempotency_key: str | None = None + self, + *, + vin: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> AutotekaPreviewInfo: - """Выполняет публичную операцию `AutotekaVehicle.create_preview_by_vin` и возвращает типизированную SDK-модель. + """Создает preview автомобиля Автотеки по VIN. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + vin: передает VIN автомобиля. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `AutotekaPreviewInfo` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return PreviewClient(self.transport).create_by_vin( - vin=vin, + return self._execute( + CREATE_PREVIEW_BY_VIN, + request=VinRequest(vin=vin), + headers=_autoteka_headers(self.transport), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -109,16 +215,36 @@ def create_preview_by_vin( spec="Автотека.json", operation_id="getPreview", ) - def get_preview(self, *, preview_id: int | str | None = None) -> AutotekaPreviewInfo: - """Выполняет публичную операцию `AutotekaVehicle.get_preview` и возвращает типизированную SDK-модель. + def get_preview( + self, + *, + preview_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaPreviewInfo: + """Возвращает preview для автомобилей Автотеки. + + Аргументы: + preview_id: идентифицирует preview Автотеки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `AutotekaPreviewInfo` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return PreviewClient(self.transport).get_preview( - preview_id=preview_id or self._require_vehicle_id("preview_id") + return self._execute( + GET_PREVIEW, + path_params={"previewId": preview_id or self._require_vehicle_id("preview_id")}, + headers=_autoteka_headers(self.transport), + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -126,7 +252,7 @@ def get_preview(self, *, preview_id: int | str | None = None) -> AutotekaPreview "/autoteka/v1/request-preview-by-external-item", spec="Автотека.json", operation_id="postPreviewByExternalItem", - method_args={"item_id": "body.item_id", "site": "body.site"}, + method_args={"item_id": "body.itemId", "site": "body.site"}, ) def create_preview_by_external_item( self, @@ -134,20 +260,36 @@ def create_preview_by_external_item( item_id: str, site: str, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> AutotekaPreviewInfo: - """Выполняет публичную операцию `AutotekaVehicle.create_preview_by_external_item` и возвращает типизированную SDK-модель. + """Создает preview автомобиля Автотеки по внешнему объявлению. + + Аргументы: + item_id: идентифицирует объявление Авито. + site: задает площадку внешнего объявления. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `AutotekaPreviewInfo` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return PreviewClient(self.transport).create_by_external_item( - item_id=item_id, - site=site, + return self._execute( + CREATE_PREVIEW_BY_EXTERNAL_ITEM, + request=ExternalItemPreviewRequest(item_id=item_id, site=site), + headers=_autoteka_headers(self.transport), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -158,20 +300,39 @@ def create_preview_by_external_item( method_args={"item_id": "body.item_id"}, ) def create_preview_by_item_id( - self, *, item_id: int, idempotency_key: str | None = None + self, + *, + item_id: int, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> AutotekaPreviewInfo: - """Выполняет публичную операцию `AutotekaVehicle.create_preview_by_item_id` и возвращает типизированную SDK-модель. + """Создает preview автомобиля Автотеки по объявлению Авито. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + item_id: идентифицирует объявление Авито. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `AutotekaPreviewInfo` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return PreviewClient(self.transport).create_by_item_id( - item_id=item_id, + return self._execute( + CREATE_PREVIEW_BY_ITEM_ID, + request=ItemIdRequest(item_id=item_id), + headers=_autoteka_headers(self.transport), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -182,20 +343,39 @@ def create_preview_by_item_id( method_args={"reg_number": "body.reg_number"}, ) def create_preview_by_reg_number( - self, *, reg_number: str, idempotency_key: str | None = None + self, + *, + reg_number: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> AutotekaPreviewInfo: - """Выполняет публичную операцию `AutotekaVehicle.create_preview_by_reg_number` и возвращает типизированную SDK-модель. + """Создает preview автомобиля Автотеки по госномеру. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + reg_number: передает государственный номер автомобиля. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `AutotekaPreviewInfo` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return PreviewClient(self.transport).create_by_reg_number( - reg_number=reg_number, + return self._execute( + CREATE_PREVIEW_BY_REG_NUMBER, + request=RegNumberRequest(reg_number=reg_number), + headers=_autoteka_headers(self.transport), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -206,20 +386,39 @@ def create_preview_by_reg_number( method_args={"plate_number": "body.plate_number"}, ) def create_specification_by_plate_number( - self, *, plate_number: str, idempotency_key: str | None = None + self, + *, + plate_number: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> AutotekaSpecificationInfo: - """Выполняет публичную операцию `AutotekaVehicle.create_specification_by_plate_number` и возвращает типизированную SDK-модель. + """Создает спецификацию автомобиля Автотеки по номерному знаку. + + Аргументы: + plate_number: передает номерной знак автомобиля. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `AutotekaSpecificationInfo` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SpecificationsClient(self.transport).create_by_plate_number( - plate_number=plate_number, + return self._execute( + CREATE_SPECIFICATION_BY_PLATE_NUMBER, + request=PlateNumberRequest(plate_number=plate_number), + headers=_autoteka_headers(self.transport), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -230,20 +429,39 @@ def create_specification_by_plate_number( method_args={"vehicle_id": "body.vehicle_id"}, ) def create_specification_by_vehicle_id( - self, *, vehicle_id: str, idempotency_key: str | None = None + self, + *, + vehicle_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> AutotekaSpecificationInfo: - """Выполняет публичную операцию `AutotekaVehicle.create_specification_by_vehicle_id` и возвращает типизированную SDK-модель. + """Создает спецификацию автомобиля Автотеки по vehicle_id. + + Аргументы: + vehicle_id: идентифицирует автомобиль в Автотеке. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `AutotekaSpecificationInfo` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SpecificationsClient(self.transport).create_by_vehicle_id( - vehicle_id=vehicle_id, + return self._execute( + CREATE_SPECIFICATION_BY_VEHICLE_ID, + request=VehicleIdRequest(vehicle_id=vehicle_id), + headers=_autoteka_headers(self.transport), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -256,16 +474,34 @@ def get_specification_by_id( self, *, specification_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> AutotekaSpecificationInfo: - """Выполняет публичную операцию `AutotekaVehicle.get_specification_by_id` и возвращает типизированную SDK-модель. + """Возвращает спецификацию автомобиля Автотеки по идентификатору. + + Аргументы: + specification_id: идентифицирует спецификацию автомобиля. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `AutotekaSpecificationInfo` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SpecificationsClient(self.transport).get_by_id( - specification_id=specification_id or self._require_vehicle_id("specification_id") + return self._execute( + GET_SPECIFICATION_BY_ID, + path_params={ + "specificationID": specification_id or self._require_vehicle_id("specification_id") + }, + headers=_autoteka_headers(self.transport), + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -276,20 +512,39 @@ def get_specification_by_id( method_args={"vehicle_id": "body.vehicle_id"}, ) def create_teaser( - self, *, vehicle_id: str, idempotency_key: str | None = None + self, + *, + vehicle_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> AutotekaTeaserInfo: - """Выполняет публичную операцию `AutotekaVehicle.create_teaser` и возвращает типизированную SDK-модель. + """Создает тизер автомобиля Автотеки. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + vehicle_id: идентифицирует автомобиль в Автотеке. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `AutotekaTeaserInfo` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return TeaserClient(self.transport).create( - vehicle_id=vehicle_id, + return self._execute( + CREATE_TEASER, + request=TeaserCreateRequest(vehicle_id=vehicle_id), + headers=_autoteka_headers(self.transport), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -298,16 +553,36 @@ def create_teaser( spec="Автотека.json", operation_id="getTeaser", ) - def get_teaser(self, *, teaser_id: int | str | None = None) -> AutotekaTeaserInfo: - """Выполняет публичную операцию `AutotekaVehicle.get_teaser` и возвращает типизированную SDK-модель. + def get_teaser( + self, + *, + teaser_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaTeaserInfo: + """Возвращает teaser для автомобилей Автотеки. + + Аргументы: + teaser_id: идентифицирует тизер Автотеки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `AutotekaTeaserInfo` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return TeaserClient(self.transport).get( - teaser_id=teaser_id or self._require_vehicle_id("teaser_id") + return self._execute( + GET_TEASER, + path_params={"teaser_id": teaser_id or self._require_vehicle_id("teaser_id")}, + headers=_autoteka_headers(self.transport), + timeout=timeout, + retry=retry, ) def _require_vehicle_id(self, field_name: str) -> str: @@ -333,15 +608,31 @@ class AutotekaReport(DomainObject): spec="Автотека.json", operation_id="getActivePackage", ) - def get_active_package(self) -> AutotekaPackageInfo: - """Выполняет публичную операцию `AutotekaReport.get_active_package` и возвращает типизированную SDK-модель. + def get_active_package( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutotekaPackageInfo: + """Возвращает active package для отчетов Автотеки. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `AutotekaPackageInfo` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return ReportClient(self.transport).get_active_package() + return self._execute( + GET_ACTIVE_PACKAGE, + headers=_autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) @swagger_operation( "POST", @@ -351,20 +642,39 @@ def get_active_package(self) -> AutotekaPackageInfo: method_args={"preview_id": "body.preview_id"}, ) def create_report( - self, *, preview_id: int, idempotency_key: str | None = None + self, + *, + preview_id: int, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> AutotekaReportInfo: - """Выполняет публичную операцию `AutotekaReport.create_report` и возвращает типизированную SDK-модель. + """Создает отчет Автотеки по preview. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + preview_id: идентифицирует preview Автотеки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `AutotekaReportInfo` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return ReportClient(self.transport).create_report( - preview_id=preview_id, + return self._execute( + CREATE_REPORT, + request=PreviewReportRequest(preview_id=preview_id), + headers=_autoteka_headers(self.transport), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -375,20 +685,39 @@ def create_report( method_args={"vehicle_id": "body.vehicle_id"}, ) def create_report_by_vehicle_id( - self, *, vehicle_id: str, idempotency_key: str | None = None + self, + *, + vehicle_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> AutotekaReportInfo: - """Выполняет публичную операцию `AutotekaReport.create_report_by_vehicle_id` и возвращает типизированную SDK-модель. + """Создает отчет Автотеки по vehicle_id. + + Аргументы: + vehicle_id: идентифицирует автомобиль в Автотеке. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `AutotekaReportInfo` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return ReportClient(self.transport).create_report_by_vehicle_id( - vehicle_id=vehicle_id, + return self._execute( + CREATE_REPORT_BY_VEHICLE_ID, + request=VehicleIdRequest(vehicle_id=vehicle_id), + headers=_autoteka_headers(self.transport), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -397,15 +726,31 @@ def create_report_by_vehicle_id( spec="Автотека.json", operation_id="getReportList", ) - def list_reports(self) -> AutotekaReportsResult: - """Получает список отчетов Автотеки. + def list_reports( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutotekaReportsResult: + """Возвращает список reports для отчетов Автотеки. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Возвращает: + `AutotekaReportsResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return ReportClient(self.transport).list_reports() + return self._execute( + LIST_REPORTS, + headers=_autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) @swagger_operation( "GET", @@ -413,16 +758,36 @@ def list_reports(self) -> AutotekaReportsResult: spec="Автотека.json", operation_id="getReport", ) - def get_report(self, *, report_id: int | str | None = None) -> AutotekaReportInfo: - """Выполняет публичную операцию `AutotekaReport.get_report` и возвращает типизированную SDK-модель. + def get_report( + self, + *, + report_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaReportInfo: + """Возвращает report для отчетов Автотеки. + + Аргументы: + report_id: идентифицирует отчет Автотеки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `AutotekaReportInfo` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return ReportClient(self.transport).get_report( - report_id=report_id or self._require_report_id() + return self._execute( + GET_REPORT, + path_params={"report_id": report_id or self._require_report_id()}, + headers=_autoteka_headers(self.transport), + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -433,20 +798,39 @@ def get_report(self, *, report_id: int | str | None = None) -> AutotekaReportInf method_args={"reg_number": "body.reg_number"}, ) def create_sync_report_by_reg_number( - self, *, reg_number: str, idempotency_key: str | None = None + self, + *, + reg_number: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> AutotekaReportInfo: - """Выполняет публичную операцию `AutotekaReport.create_sync_report_by_reg_number` и возвращает типизированную SDK-модель. + """Создает синхронный отчет Автотеки по госномеру. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + reg_number: передает государственный номер автомобиля. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `AutotekaReportInfo` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return ReportClient(self.transport).create_sync_report_by_reg_number( - reg_number=reg_number, + return self._execute( + CREATE_SYNC_REPORT_BY_REG_NUMBER, + request=RegNumberRequest(reg_number=reg_number), + headers=_autoteka_headers(self.transport), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -457,20 +841,39 @@ def create_sync_report_by_reg_number( method_args={"vin": "body.vin"}, ) def create_sync_report_by_vin( - self, *, vin: str, idempotency_key: str | None = None + self, + *, + vin: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> AutotekaReportInfo: - """Выполняет публичную операцию `AutotekaReport.create_sync_report_by_vin` и возвращает типизированную SDK-модель. + """Создает синхронный отчет Автотеки по VIN. + + Аргументы: + vin: передает VIN автомобиля. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `AutotekaReportInfo` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return ReportClient(self.transport).create_sync_report_by_vin( - vin=vin, + return self._execute( + CREATE_SYNC_REPORT_BY_VIN, + request=VinRequest(vin=vin), + headers=_autoteka_headers(self.transport), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) def _require_report_id(self) -> str: @@ -496,20 +899,39 @@ class AutotekaMonitoring(DomainObject): method_args={"vehicles": "body.data"}, ) def create_monitoring_bucket_add( - self, *, vehicles: list[str], idempotency_key: str | None = None + self, + *, + vehicles: list[str], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> MonitoringBucketResult: - """Выполняет публичную операцию `AutotekaMonitoring.create_monitoring_bucket_add` и возвращает типизированную SDK-модель. + """Создает monitoring bucket add для мониторинга Автотеки. + + Аргументы: + vehicles: передает автомобили для добавления в мониторинг. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `MonitoringBucketResult` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return MonitoringClient(self.transport).add_bucket( - vehicles=vehicles, + return self._execute( + ADD_MONITORING_BUCKET, + request=MonitoringBucketRequest(vehicles=vehicles), + headers=_autoteka_headers(self.transport), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -518,15 +940,38 @@ def create_monitoring_bucket_add( spec="Автотека.json", operation_id="monitoringBucketDelete", ) - def delete_bucket(self, *, idempotency_key: str | None = None) -> MonitoringBucketResult: + def delete_bucket( + self, + *, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MonitoringBucketResult: """Очищает bucket мониторинга. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MonitoringBucketResult` со статусом операции над bucket мониторинга. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return MonitoringClient(self.transport).delete_bucket(idempotency_key=idempotency_key) + return self._execute( + DELETE_MONITORING_BUCKET, + headers=_autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) @swagger_operation( "POST", @@ -536,18 +981,39 @@ def delete_bucket(self, *, idempotency_key: str | None = None) -> MonitoringBuck method_args={"vehicles": "body.data"}, ) def remove_bucket( - self, *, vehicles: list[str], idempotency_key: str | None = None + self, + *, + vehicles: list[str], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> MonitoringBucketResult: """Удаляет автомобили из bucket мониторинга. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + vehicles: передает идентификаторы автомобилей для удаления из bucket. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MonitoringBucketResult` со статусом операции над bucket мониторинга. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return MonitoringClient(self.transport).remove_bucket( - vehicles=vehicles, + return self._execute( + REMOVE_MONITORING_BUCKET, + request=MonitoringBucketRequest(vehicles=vehicles), + headers=_autoteka_headers(self.transport), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -559,16 +1025,35 @@ def remove_bucket( def get_monitoring_reg_actions( self, *, - query: MonitoringEventsQuery | None = None, + limit: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> MonitoringEventsResult: - """Выполняет публичную операцию `AutotekaMonitoring.get_monitoring_reg_actions` и возвращает типизированную SDK-модель. + """Возвращает monitoring reg actions для мониторинга Автотеки. + + Аргументы: + limit: ограничивает размер возвращаемой выборки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MonitoringEventsResult` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return MonitoringClient(self.transport).get_reg_actions(query=query) + return self._execute( + GET_MONITORING_REG_ACTIONS, + query=MonitoringEventsQuery(limit=limit), + headers=_autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) @dataclass(slots=True, frozen=True) @@ -590,20 +1075,39 @@ class AutotekaScoring(DomainObject): method_args={"vehicle_id": "body.vehicle_id"}, ) def create_scoring_by_vehicle_id( - self, *, vehicle_id: str, idempotency_key: str | None = None + self, + *, + vehicle_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> AutotekaScoringInfo: - """Выполняет публичную операцию `AutotekaScoring.create_scoring_by_vehicle_id` и возвращает типизированную SDK-модель. + """Создает расчет скоринга Автотеки по vehicle_id. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + vehicle_id: идентифицирует автомобиль в Автотеке. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `AutotekaScoringInfo` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return ScoringClient(self.transport).create_by_vehicle_id( - vehicle_id=vehicle_id, + return self._execute( + CREATE_SCORING_BY_VEHICLE_ID, + request=VehicleIdRequest(vehicle_id=vehicle_id), + headers=_autoteka_headers(self.transport), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -612,16 +1116,36 @@ def create_scoring_by_vehicle_id( spec="Автотека.json", operation_id="scoringGetById", ) - def get_scoring_by_id(self, *, scoring_id: int | str | None = None) -> AutotekaScoringInfo: - """Выполняет публичную операцию `AutotekaScoring.get_scoring_by_id` и возвращает типизированную SDK-модель. + def get_scoring_by_id( + self, + *, + scoring_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaScoringInfo: + """Возвращает расчет скоринга Автотеки по идентификатору. + + Аргументы: + scoring_id: идентифицирует расчет скоринга. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `AutotekaScoringInfo` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return ScoringClient(self.transport).get_by_id( - scoring_id=scoring_id or self._require_scoring_id() + return self._execute( + GET_SCORING_BY_ID, + path_params={"scoring_id": scoring_id or self._require_scoring_id()}, + headers=_autoteka_headers(self.transport), + timeout=timeout, + retry=retry, ) def _require_scoring_id(self) -> str: @@ -644,20 +1168,46 @@ class AutotekaValuation(DomainObject): "/autoteka/v1/valuation/by-specification", spec="Автотека.json", operation_id="valuationBySpecification", - method_args={"specification_id": "body.specification", "mileage": "body.mileage"}, + method_args={ + "specification_id": "body.specification.brand.valueId", + "mileage": "body.mileage", + }, ) def get_valuation_by_specification( - self, *, specification_id: int, mileage: int + self, + *, + specification_id: int, + mileage: int, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> AutotekaValuationInfo: - """Выполняет публичную операцию `AutotekaValuation.get_valuation_by_specification` и возвращает типизированную SDK-модель. + """Возвращает оценку автомобиля Автотеки по спецификации. + + Аргументы: + specification_id: идентифицирует спецификацию автомобиля. + mileage: передает пробег автомобиля. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaValuationInfo` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return ValuationClient(self.transport).get_by_specification( - ValuationBySpecificationRequest(specification_id=specification_id, mileage=mileage) + return self._execute( + GET_VALUATION_BY_SPECIFICATION, + request=ValuationBySpecificationRequest( + specification_id=specification_id, + mileage=mileage, + ), + headers=_autoteka_headers(self.transport), + timeout=timeout, + retry=retry, ) diff --git a/avito/autoteka/enums.py b/avito/autoteka/enums.py deleted file mode 100644 index cfdbf58..0000000 --- a/avito/autoteka/enums.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Enum-значения раздела autoteka.""" - -from __future__ import annotations - -from enum import Enum - - -class AutotekaStatus(str, Enum): - """Статус сущности Автотеки.""" - - UNKNOWN = "__unknown__" - PROCESSING = "processing" - SUCCESS = "success" - NOT_FOUND = "notFound" - INCOMPLETE = "incomplete" - OK = "ok" - WARNING = "warning" - - -__all__ = ("AutotekaStatus",) diff --git a/avito/autoteka/mappers.py b/avito/autoteka/mappers.py deleted file mode 100644 index 243dd00..0000000 --- a/avito/autoteka/mappers.py +++ /dev/null @@ -1,322 +0,0 @@ -"""Мапперы JSON -> dataclass для пакета autoteka.""" - -from __future__ import annotations - -from collections.abc import Mapping -from typing import cast - -from avito.autoteka.enums import AutotekaStatus -from avito.autoteka.models import ( - AutotekaLeadEvent, - AutotekaLeadsResult, - AutotekaPackageInfo, - AutotekaPreviewInfo, - AutotekaReportInfo, - AutotekaReportsResult, - AutotekaScoringInfo, - AutotekaSpecificationInfo, - AutotekaTeaserInfo, - AutotekaValuationInfo, - CatalogField, - CatalogFieldValue, - CatalogResolveResult, - MonitoringBucketResult, - MonitoringEvent, - MonitoringEventsResult, - MonitoringInvalidVehicle, -) -from avito.core.enums import map_enum_or_unknown -from avito.core.exceptions import ResponseMappingError - -Payload = Mapping[str, object] - - -def _expect_mapping(payload: object) -> Payload: - if not isinstance(payload, Mapping): - raise ResponseMappingError("Ожидался JSON-объект.", payload=payload) - return cast(Payload, payload) - - -def _mapping(payload: Payload, *keys: str) -> Payload: - for key in keys: - value = payload.get(key) - if isinstance(value, Mapping): - return cast(Payload, value) - return {} - - -def _list(payload: Payload, *keys: str) -> list[Payload]: - for key in keys: - value = payload.get(key) - if isinstance(value, list): - return [item for item in value if isinstance(item, Mapping)] - return [] - - -def _str(payload: Payload, *keys: str) -> str | None: - for key in keys: - value = payload.get(key) - if isinstance(value, str): - return value - if isinstance(value, int) and not isinstance(value, bool): - return str(value) - return None - - -def _int(payload: Payload, *keys: str) -> int | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - continue - if isinstance(value, int): - return value - return None - - -def _bool(payload: Payload, *keys: str) -> bool | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - return value - return None - - -def map_catalogs_resolve(payload: object) -> CatalogResolveResult: - """Преобразует ответ автокаталога.""" - - data = _expect_mapping(payload) - result = _mapping(data, "result") - return CatalogResolveResult( - items=[ - CatalogField( - field_id=_str(item, "id", "fieldId"), - label=_str(item, "label"), - data_type=_str(item, "dataType", "type"), - values=[ - CatalogFieldValue( - value_id=_str(value, "valueId", "id"), - label=_str(value, "label", "value"), - ) - for value in _list(item, "values", "items") - ], - ) - for item in _list(result, "fields", "items") - ], - ) - - -def map_leads(payload: object) -> AutotekaLeadsResult: - """Преобразует события сервиса Сигнал.""" - - data = _expect_mapping(payload) - pagination = _mapping(data, "pagination") - items = [] - for item in _list(data, "result", "items"): - event_payload = _mapping(item, "payload") - items.append( - AutotekaLeadEvent( - event_id=_str(item, "id"), - subscription_id=_str(item, "subscriptionId"), - vehicle_id=_str(event_payload, "vin", "vehicleId"), - item_id=_int(event_payload, "itemId"), - brand=_str(event_payload, "brand"), - model=_str(event_payload, "model"), - price=_int(event_payload, "price"), - created_at=_str(event_payload, "itemCreatedAt"), - url=_str(event_payload, "url"), - ) - ) - return AutotekaLeadsResult(items=items, last_id=_int(pagination, "lastId")) - - -def map_monitoring_bucket(payload: object) -> MonitoringBucketResult: - """Преобразует результат изменения корзины мониторинга.""" - - data = _expect_mapping(payload) - result = _mapping(data, "result") - return MonitoringBucketResult( - success=bool(result.get("isOk", False)), - invalid_vehicles=[ - MonitoringInvalidVehicle( - vehicle_id=_str(item, "vehicleID", "vehicleId"), - description=_str(item, "description"), - ) - for item in _list(result, "invalidVehicles", "items") - ], - ) - - -def map_monitoring_events(payload: object) -> MonitoringEventsResult: - """Преобразует события мониторинга.""" - - data = _expect_mapping(payload) - pagination = _mapping(data, "pagination") - return MonitoringEventsResult( - items=[ - MonitoringEvent( - vehicle_id=_str(item, "vin", "vehicleId"), - brand=_str(item, "brand"), - model=_str(item, "model"), - year=_int(item, "year"), - operation_code=_int(item, "operationCode"), - operation_date_from=_str(item, "operationDateFrom", "operationDate"), - operation_date_to=_str(item, "operationDateTo"), - owner_code=_int(item, "ownerCode"), - actual_at=_int(item, "actualAt"), - ) - for item in _list(data, "data", "items") - ], - has_next=_bool(pagination, "hasNext"), - next_cursor=_str(pagination, "nextCursor"), - next_link=_str(pagination, "nextLink"), - ) - - -def map_package(payload: object) -> AutotekaPackageInfo: - """Преобразует информацию о пакете отчетов.""" - - data = _expect_mapping(payload) - result = _mapping(data, "result") - package = _mapping(result, "package") - return AutotekaPackageInfo( - reports_total=_int(package, "reportsCnt"), - reports_remaining=_int(package, "reportsCntRemain"), - created_at=_str(package, "createdTime"), - expires_at=_str(package, "expireTime"), - ) - - -def _map_preview_source(source: Payload) -> AutotekaPreviewInfo: - return AutotekaPreviewInfo( - preview_id=_str(source, "previewId"), - 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"), - ) - - -def map_preview(payload: object) -> AutotekaPreviewInfo: - """Преобразует превью автомобиля.""" - - data = _expect_mapping(payload) - result = _mapping(data, "result") - preview = _mapping(result, "preview") - source = preview or result or data - return _map_preview_source(source) - - -def _map_report_source(source: Payload) -> AutotekaReportInfo: - data = _mapping(source, "data") - return AutotekaReportInfo( - report_id=_str(source, "reportId"), - 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"), - pdf_link=_str(source, "pdfLink"), - ) - - -def map_report(payload: object) -> AutotekaReportInfo: - """Преобразует один отчет Автотеки.""" - - data = _expect_mapping(payload) - result = _mapping(data, "result") - report = _mapping(result, "report") - source = report or result or data - return _map_report_source(source) - - -def map_reports(payload: object) -> AutotekaReportsResult: - """Преобразует список отчетов.""" - - data = _expect_mapping(payload) - return AutotekaReportsResult( - items=[_map_report_source(item) for item in _list(data, "result", "items")], - ) - - -def map_scoring(payload: object) -> AutotekaScoringInfo: - """Преобразует ответ скоринга.""" - - data = _expect_mapping(payload) - result = _mapping(data, "result") - scoring = _mapping(result, "scoring", "risksAssessment") - source = scoring or result or data - return AutotekaScoringInfo( - scoring_id=_str(source, "scoringId"), - is_completed=_bool(source, "isCompleted"), - created_at=_int(source, "createdAt"), - ) - - -def map_specification(payload: object) -> AutotekaSpecificationInfo: - """Преобразует ответ спецификации.""" - - data = _expect_mapping(payload) - result = _mapping(data, "result") - specification = _mapping(result, "specification") - source = specification or result or data - return AutotekaSpecificationInfo( - specification_id=_str(source, "specificationId"), - status=map_enum_or_unknown( - _str(source, "status"), - AutotekaStatus, - enum_name="autoteka.status", - ), - vehicle_id=_str(source, "vehicleId"), - plate_number=_str(source, "plateNumber"), - ) - - -def map_teaser(payload: object) -> AutotekaTeaserInfo: - """Преобразует ответ тизера.""" - - data = _expect_mapping(payload) - result = _mapping(data, "result") - teaser_wrapper = _mapping(result, "teaser") - teaser_data = _mapping(teaser_wrapper, "data") or _mapping(data, "data") - source = teaser_wrapper or result or data - return AutotekaTeaserInfo( - teaser_id=_str(source, "teaserId"), - 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"), - ) - - -def map_valuation(payload: object) -> AutotekaValuationInfo: - """Преобразует ответ оценки автомобиля.""" - - data = _expect_mapping(payload) - result = _mapping(data, "result") - valuation = _mapping(result, "valuation") - source = result or data - return AutotekaValuationInfo( - 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"), - year=_int(source, "year"), - owners_count=_str(source, "ownersCount"), - mileage=_int(source, "mileage"), - avg_price_with_condition=_int(valuation, "avgPriceWithCondition"), - avg_market_price=_int(valuation, "avgMarketPrice"), - ) diff --git a/avito/autoteka/models.py b/avito/autoteka/models.py index 25b5e93..8d4d145 100644 --- a/avito/autoteka/models.py +++ b/avito/autoteka/models.py @@ -3,13 +3,26 @@ from __future__ import annotations from dataclasses import dataclass +from enum import StrEnum +from typing import cast -from avito.autoteka.enums import AutotekaStatus -from avito.core.serialization import SerializableModel +from avito.core.enums import map_enum_or_unknown +from avito.core.exceptions import ResponseMappingError +from avito.core.models import ApiModel, RequestModel + + +class AutotekaStatus(StrEnum): + """Статус сущности Автотеки.""" + + PROCESSING = "processing" + SUCCESS = "success" + FAILED = "failed" + ERROR = "error" + UNKNOWN = "unknown" @dataclass(slots=True, frozen=True) -class CatalogResolveRequest: +class CatalogResolveRequest(RequestModel): """Запрос актуализации параметров автокаталога.""" brand_id: int @@ -17,23 +30,30 @@ class CatalogResolveRequest: def to_payload(self) -> dict[str, object]: """Сериализует запрос автокаталога.""" - return {"brandId": self.brand_id} + return {"fieldsValueIds": [{"id": 110000, "valueId": self.brand_id}]} @dataclass(slots=True, frozen=True) -class LeadsRequest: +class LeadsRequest(RequestModel): """Запрос событий сервиса Сигнал.""" + subscription_id: int limit: int + last_id: int | None = None def to_payload(self) -> dict[str, object]: """Сериализует запрос событий Сигнал.""" - return {"limit": self.limit} + payload: dict[str, object] = {"subscriptionId": self.subscription_id} + if self.limit is not None: + payload["limit"] = self.limit + if self.last_id is not None: + payload["lastId"] = self.last_id + return payload @dataclass(slots=True, frozen=True) -class VinRequest: +class VinRequest(RequestModel): """Запрос по VIN.""" vin: str @@ -45,7 +65,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class VehicleIdRequest: +class VehicleIdRequest(RequestModel): """Запрос по идентификатору автомобиля.""" vehicle_id: str @@ -57,7 +77,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class ItemIdRequest: +class ItemIdRequest(RequestModel): """Запрос по идентификатору объявления.""" item_id: int @@ -69,7 +89,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class ExternalItemPreviewRequest: +class ExternalItemPreviewRequest(RequestModel): """Запрос превью по внешнему объявлению.""" item_id: str @@ -82,7 +102,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class RegNumberRequest: +class RegNumberRequest(RequestModel): """Запрос по государственному номеру.""" reg_number: str @@ -94,7 +114,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class PlateNumberRequest: +class PlateNumberRequest(RequestModel): """Запрос по номерному знаку.""" plate_number: str @@ -106,7 +126,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class PreviewReportRequest: +class PreviewReportRequest(RequestModel): """Запрос отчета по preview id.""" preview_id: int @@ -118,7 +138,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class MonitoringBucketRequest: +class MonitoringBucketRequest(RequestModel): """Запрос изменения списка мониторинга.""" vehicles: list[str] @@ -126,11 +146,11 @@ class MonitoringBucketRequest: def to_payload(self) -> dict[str, object]: """Сериализует запрос изменения списка мониторинга.""" - return {"vehicles": list(self.vehicles)} + return {"data": list(self.vehicles)} @dataclass(slots=True, frozen=True) -class MonitoringEventsQuery: +class MonitoringEventsQuery(RequestModel): """Query событий мониторинга.""" limit: int | None = None @@ -145,7 +165,7 @@ def to_params(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class TeaserCreateRequest: +class TeaserCreateRequest(RequestModel): """Запрос создания тизера.""" vehicle_id: str @@ -157,7 +177,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class ValuationBySpecificationRequest: +class ValuationBySpecificationRequest(RequestModel): """Запрос оценки автомобиля по specification id.""" specification_id: int @@ -166,11 +186,22 @@ class ValuationBySpecificationRequest: def to_payload(self) -> dict[str, object]: """Сериализует запрос оценки автомобиля.""" - return {"specificationId": self.specification_id, "mileage": self.mileage} + empty_choice = {"label": "", "valueId": 0} + return { + "specification": { + "brand": {"label": "", "valueId": self.specification_id}, + "model": empty_choice, + "year": {"label": "", "valueId": self.specification_id}, + "generation": empty_choice, + "modification": empty_choice, + "ownersCount": empty_choice, + }, + "mileage": self.mileage, + } @dataclass(slots=True, frozen=True) -class CatalogFieldValue(SerializableModel): +class CatalogFieldValue(ApiModel): """Значение параметра автокаталога.""" value_id: str | None @@ -178,7 +209,7 @@ class CatalogFieldValue(SerializableModel): @dataclass(slots=True, frozen=True) -class CatalogField(SerializableModel): +class CatalogField(ApiModel): """Параметр автокаталога.""" field_id: str | None @@ -188,14 +219,38 @@ class CatalogField(SerializableModel): @dataclass(slots=True, frozen=True) -class CatalogResolveResult(SerializableModel): +class CatalogResolveResult(ApiModel): """Результат актуализации параметров автокаталога.""" items: list[CatalogField] + @classmethod + def from_payload(cls, payload: object) -> CatalogResolveResult: + """Преобразует ответ автокаталога.""" + + data = _expect_mapping(payload) + result = _mapping(data, "result") + return cls( + items=[ + CatalogField( + field_id=_str(item, "id", "fieldId"), + label=_str(item, "label"), + data_type=_str(item, "dataType", "type"), + values=[ + CatalogFieldValue( + value_id=_str(value, "valueId", "id"), + label=_str(value, "label", "value"), + ) + for value in _list(item, "values", "items") + ], + ) + for item in _list(result, "fields", "items") + ], + ) + @dataclass(slots=True, frozen=True) -class AutotekaLeadEvent(SerializableModel): +class AutotekaLeadEvent(ApiModel): """Событие сервиса Сигнал.""" event_id: str | None @@ -210,15 +265,39 @@ class AutotekaLeadEvent(SerializableModel): @dataclass(slots=True, frozen=True) -class AutotekaLeadsResult(SerializableModel): +class AutotekaLeadsResult(ApiModel): """Список событий сервиса Сигнал.""" items: list[AutotekaLeadEvent] last_id: int | None = None + @classmethod + def from_payload(cls, payload: object) -> AutotekaLeadsResult: + """Преобразует события сервиса Сигнал.""" + + data = _expect_mapping(payload) + pagination = _mapping(data, "pagination") + items = [] + for item in _list(data, "result", "items"): + event_payload = _mapping(item, "payload") + items.append( + AutotekaLeadEvent( + event_id=_str(item, "id"), + subscription_id=_str(item, "subscriptionId"), + vehicle_id=_str(event_payload, "vin", "vehicleId"), + item_id=_int(event_payload, "itemId"), + brand=_str(event_payload, "brand"), + model=_str(event_payload, "model"), + price=_int(event_payload, "price"), + created_at=_str(event_payload, "itemCreatedAt"), + url=_str(event_payload, "url"), + ) + ) + return cls(items=items, last_id=_int(pagination, "lastId")) + @dataclass(slots=True, frozen=True) -class MonitoringInvalidVehicle(SerializableModel): +class MonitoringInvalidVehicle(ApiModel): """Невалидный идентификатор авто в запросах мониторинга.""" vehicle_id: str | None @@ -226,15 +305,32 @@ class MonitoringInvalidVehicle(SerializableModel): @dataclass(slots=True, frozen=True) -class MonitoringBucketResult(SerializableModel): +class MonitoringBucketResult(ApiModel): """Результат изменения списка мониторинга.""" success: bool invalid_vehicles: list[MonitoringInvalidVehicle] + @classmethod + def from_payload(cls, payload: object) -> MonitoringBucketResult: + """Преобразует результат изменения bucket мониторинга.""" + + data = _expect_mapping(payload) + result = _mapping(data, "result") + return cls( + success=bool(result.get("isOk", False)), + invalid_vehicles=[ + MonitoringInvalidVehicle( + vehicle_id=_str(item, "vehicleID", "vehicleId"), + description=_str(item, "description"), + ) + for item in _list(result, "invalidVehicles", "items") + ], + ) + @dataclass(slots=True, frozen=True) -class MonitoringEvent(SerializableModel): +class MonitoringEvent(ApiModel): """Событие мониторинга регистрационных действий.""" vehicle_id: str | None @@ -249,7 +345,7 @@ class MonitoringEvent(SerializableModel): @dataclass(slots=True, frozen=True) -class MonitoringEventsResult(SerializableModel): +class MonitoringEventsResult(ApiModel): """Список событий мониторинга.""" items: list[MonitoringEvent] @@ -257,9 +353,35 @@ class MonitoringEventsResult(SerializableModel): next_cursor: str | None = None next_link: str | None = None + @classmethod + def from_payload(cls, payload: object) -> MonitoringEventsResult: + """Преобразует события мониторинга.""" + + data = _expect_mapping(payload) + pagination = _mapping(data, "pagination") + return cls( + items=[ + MonitoringEvent( + vehicle_id=_str(item, "vin", "vehicleId"), + brand=_str(item, "brand"), + model=_str(item, "model"), + year=_int(item, "year"), + operation_code=_int(item, "operationCode"), + operation_date_from=_str(item, "operationDateFrom", "operationDate"), + operation_date_to=_str(item, "operationDateTo"), + owner_code=_int(item, "ownerCode"), + actual_at=_int(item, "actualAt"), + ) + for item in _list(data, "data", "items") + ], + has_next=_bool(pagination, "hasNext"), + next_cursor=_str(pagination, "nextCursor"), + next_link=_str(pagination, "nextLink"), + ) + @dataclass(slots=True, frozen=True) -class AutotekaPackageInfo(SerializableModel): +class AutotekaPackageInfo(ApiModel): """Информация о текущем пакете отчетов Автотеки.""" reports_total: int | None @@ -267,9 +389,23 @@ class AutotekaPackageInfo(SerializableModel): created_at: str | None expires_at: str | None + @classmethod + def from_payload(cls, payload: object) -> AutotekaPackageInfo: + """Преобразует информацию о пакете отчетов.""" + + data = _expect_mapping(payload) + result = _mapping(data, "result") + package = _mapping(result, "package") + return cls( + reports_total=_int(package, "reportsCnt"), + reports_remaining=_int(package, "reportsCntRemain"), + created_at=_str(package, "createdTime"), + expires_at=_str(package, "expireTime"), + ) + @dataclass(slots=True, frozen=True) -class AutotekaPreviewInfo(SerializableModel): +class AutotekaPreviewInfo(ApiModel): """Информация о превью автомобиля.""" preview_id: str | None @@ -277,9 +413,24 @@ class AutotekaPreviewInfo(SerializableModel): vehicle_id: str | None reg_number: str | None + @classmethod + def from_payload(cls, payload: object) -> AutotekaPreviewInfo: + """Преобразует превью автомобиля.""" + + data = _expect_mapping(payload) + result = _mapping(data, "result") + preview = _mapping(result, "preview") + source = preview or result or data + return cls( + preview_id=_str(source, "previewId"), + status=_status(source), + vehicle_id=_str(source, "vin", "vehicleId"), + reg_number=_str(source, "regNumber", "plateNumber"), + ) + @dataclass(slots=True, frozen=True) -class AutotekaReportInfo(SerializableModel): +class AutotekaReportInfo(ApiModel): """Информация об отчете Автотеки.""" report_id: str | None @@ -289,25 +440,58 @@ class AutotekaReportInfo(SerializableModel): web_link: str | None pdf_link: str | None + @classmethod + def from_payload(cls, payload: object) -> AutotekaReportInfo: + """Преобразует один отчет Автотеки.""" + + data = _expect_mapping(payload) + result = _mapping(data, "result") + report = _mapping(result, "report") + source = report or result or data + return _map_report_source(source) + @dataclass(slots=True, frozen=True) -class AutotekaReportsResult(SerializableModel): +class AutotekaReportsResult(ApiModel): """Список отчетов Автотеки.""" items: list[AutotekaReportInfo] + @classmethod + def from_payload(cls, payload: object) -> AutotekaReportsResult: + """Преобразует список отчетов.""" + + data = _expect_mapping(payload) + return cls( + items=[_map_report_source(item) for item in _list(data, "result", "items")], + ) + @dataclass(slots=True, frozen=True) -class AutotekaScoringInfo(SerializableModel): +class AutotekaScoringInfo(ApiModel): """Информация о скоринге рисков.""" scoring_id: str | None is_completed: bool | None created_at: int | None + @classmethod + def from_payload(cls, payload: object) -> AutotekaScoringInfo: + """Преобразует ответ скоринга.""" + + data = _expect_mapping(payload) + result = _mapping(data, "result") + scoring = _mapping(result, "scoring", "risksAssessment") + source = scoring or result or data + return cls( + scoring_id=_str(source, "scoringId"), + is_completed=_bool(source, "isCompleted"), + created_at=_int(source, "createdAt"), + ) + @dataclass(slots=True, frozen=True) -class AutotekaSpecificationInfo(SerializableModel): +class AutotekaSpecificationInfo(ApiModel): """Информация о запросе спецификации автомобиля.""" specification_id: str | None @@ -315,9 +499,24 @@ class AutotekaSpecificationInfo(SerializableModel): vehicle_id: str | None plate_number: str | None + @classmethod + def from_payload(cls, payload: object) -> AutotekaSpecificationInfo: + """Преобразует ответ спецификации.""" + + data = _expect_mapping(payload) + result = _mapping(data, "result") + specification = _mapping(result, "specification") + source = specification or result or data + return cls( + specification_id=_str(source, "specificationId"), + status=_status(source), + vehicle_id=_str(source, "vehicleId"), + plate_number=_str(source, "plateNumber"), + ) + @dataclass(slots=True, frozen=True) -class AutotekaTeaserInfo(SerializableModel): +class AutotekaTeaserInfo(ApiModel): """Информация о тизере Автотеки.""" teaser_id: str | None @@ -326,9 +525,26 @@ class AutotekaTeaserInfo(SerializableModel): model: str | None = None year: int | None = None + @classmethod + def from_payload(cls, payload: object) -> AutotekaTeaserInfo: + """Преобразует ответ тизера.""" + + data = _expect_mapping(payload) + result = _mapping(data, "result") + teaser_wrapper = _mapping(result, "teaser") + teaser_data = _mapping(teaser_wrapper, "data") or _mapping(data, "data") + source = teaser_wrapper or result or data + return cls( + teaser_id=_str(source, "teaserId"), + status=_status(source), + 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"), + ) + @dataclass(slots=True, frozen=True) -class AutotekaValuationInfo(SerializableModel): +class AutotekaValuationInfo(ApiModel): """Оценка стоимости автомобиля.""" status: AutotekaStatus | None @@ -340,3 +556,96 @@ class AutotekaValuationInfo(SerializableModel): mileage: int | None avg_price_with_condition: int | None avg_market_price: int | None + + @classmethod + def from_payload(cls, payload: object) -> AutotekaValuationInfo: + """Преобразует ответ оценки автомобиля.""" + + data = _expect_mapping(payload) + result = _mapping(data, "result") + valuation = _mapping(result, "valuation") + source = result or data + return cls( + status=_status(source), + vehicle_id=_str(source, "vehicleId"), + brand=_str(source, "brand"), + model=_str(source, "model"), + year=_int(source, "year"), + owners_count=_str(source, "ownersCount"), + mileage=_int(source, "mileage"), + avg_price_with_condition=_int(valuation, "avgPriceWithCondition"), + avg_market_price=_int(valuation, "avgMarketPrice"), + ) + + +Payload = dict[str, object] + + +def _expect_mapping(payload: object) -> Payload: + if not isinstance(payload, dict): + raise ResponseMappingError("Ожидался JSON-объект.", payload=payload) + return cast(Payload, payload) + + +def _mapping(payload: Payload, *keys: str) -> Payload: + for key in keys: + value = payload.get(key) + if isinstance(value, dict): + return cast(Payload, value) + return {} + + +def _list(payload: Payload, *keys: str) -> list[Payload]: + for key in keys: + value = payload.get(key) + if isinstance(value, list): + return [cast(Payload, item) for item in value if isinstance(item, dict)] + return [] + + +def _str(payload: Payload, *keys: str) -> str | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str): + return value + if isinstance(value, int) and not isinstance(value, bool): + return str(value) + return None + + +def _int(payload: Payload, *keys: str) -> int | None: + for key in keys: + value = payload.get(key) + if isinstance(value, bool): + continue + if isinstance(value, int): + return value + return None + + +def _bool(payload: Payload, *keys: str) -> bool | None: + for key in keys: + value = payload.get(key) + if isinstance(value, bool): + return value + return None + + +def _status(payload: Payload) -> AutotekaStatus | None: + return map_enum_or_unknown( + _str(payload, "status"), + AutotekaStatus, + enum_name="autoteka.status", + ) + + +def _map_report_source(source: Payload) -> AutotekaReportInfo: + data = _mapping(source, "data") + return AutotekaReportInfo( + report_id=_str(source, "reportId"), + status=_status(source), + vehicle_id=_str(data, "vin", "vehicleId") or _str(source, "vin"), + created_at=_str(source, "createdAt") or _str(data, "createdAt"), + web_link=_str(source, "webLink"), + pdf_link=_str(source, "pdfLink"), + ) diff --git a/avito/autoteka/operations.py b/avito/autoteka/operations.py new file mode 100644 index 0000000..4e559bf --- /dev/null +++ b/avito/autoteka/operations.py @@ -0,0 +1,265 @@ +"""Operation specs for autoteka domain.""" + +from __future__ import annotations + +from avito.autoteka.models import ( + AutotekaLeadsResult, + AutotekaPackageInfo, + AutotekaPreviewInfo, + AutotekaReportInfo, + AutotekaReportsResult, + AutotekaScoringInfo, + AutotekaSpecificationInfo, + AutotekaTeaserInfo, + AutotekaValuationInfo, + CatalogResolveRequest, + CatalogResolveResult, + ExternalItemPreviewRequest, + ItemIdRequest, + LeadsRequest, + MonitoringBucketRequest, + MonitoringBucketResult, + MonitoringEventsQuery, + MonitoringEventsResult, + PlateNumberRequest, + PreviewReportRequest, + RegNumberRequest, + TeaserCreateRequest, + ValuationBySpecificationRequest, + VehicleIdRequest, + VinRequest, +) +from avito.core import OperationSpec + +CATALOG_RESOLVE = OperationSpec( + name="autoteka.catalog.resolve", + method="POST", + path="/autoteka/v1/catalogs/resolve", + request_model=CatalogResolveRequest, + response_model=CatalogResolveResult, + requires_auth=False, + retry_mode="enabled", +) +GET_LEADS = OperationSpec( + name="autoteka.leads.get", + method="POST", + path="/autoteka/v1/get-leads/", + request_model=LeadsRequest, + response_model=AutotekaLeadsResult, + requires_auth=False, + retry_mode="enabled", +) +CREATE_PREVIEW_BY_VIN = OperationSpec( + name="autoteka.preview.create_by_vin", + method="POST", + path="/autoteka/v1/previews", + request_model=VinRequest, + response_model=AutotekaPreviewInfo, + requires_auth=False, +) +CREATE_PREVIEW_BY_EXTERNAL_ITEM = OperationSpec( + name="autoteka.preview.create_by_external_item", + method="POST", + path="/autoteka/v1/request-preview-by-external-item", + request_model=ExternalItemPreviewRequest, + response_model=AutotekaPreviewInfo, + requires_auth=False, +) +CREATE_PREVIEW_BY_ITEM_ID = OperationSpec( + name="autoteka.preview.create_by_item_id", + method="POST", + path="/autoteka/v1/request-preview-by-item-id", + request_model=ItemIdRequest, + response_model=AutotekaPreviewInfo, + requires_auth=False, +) +CREATE_PREVIEW_BY_REG_NUMBER = OperationSpec( + name="autoteka.preview.create_by_reg_number", + method="POST", + path="/autoteka/v1/request-preview-by-regnumber", + request_model=RegNumberRequest, + response_model=AutotekaPreviewInfo, + requires_auth=False, +) +GET_PREVIEW = OperationSpec( + name="autoteka.preview.get", + method="GET", + path="/autoteka/v1/previews/{previewId}", + response_model=AutotekaPreviewInfo, + requires_auth=False, +) +GET_ACTIVE_PACKAGE = OperationSpec( + name="autoteka.report.get_active_package", + method="GET", + path="/autoteka/v1/packages/active_package", + response_model=AutotekaPackageInfo, + requires_auth=False, +) +CREATE_REPORT = OperationSpec( + name="autoteka.report.create", + method="POST", + path="/autoteka/v1/reports", + request_model=PreviewReportRequest, + response_model=AutotekaReportInfo, + requires_auth=False, +) +CREATE_REPORT_BY_VEHICLE_ID = OperationSpec( + name="autoteka.report.create_by_vehicle_id", + method="POST", + path="/autoteka/v1/reports-by-vehicle-id", + request_model=VehicleIdRequest, + response_model=AutotekaReportInfo, + requires_auth=False, +) +LIST_REPORTS = OperationSpec( + name="autoteka.report.list", + method="GET", + path="/autoteka/v1/reports/list/", + response_model=AutotekaReportsResult, + requires_auth=False, +) +GET_REPORT = OperationSpec( + name="autoteka.report.get", + method="GET", + path="/autoteka/v1/reports/{report_id}", + response_model=AutotekaReportInfo, + requires_auth=False, +) +CREATE_SYNC_REPORT_BY_REG_NUMBER = OperationSpec( + name="autoteka.report.create_sync_by_reg_number", + method="POST", + path="/autoteka/v1/sync/create-by-regnumber", + request_model=RegNumberRequest, + response_model=AutotekaReportInfo, + requires_auth=False, +) +CREATE_SYNC_REPORT_BY_VIN = OperationSpec( + name="autoteka.report.create_sync_by_vin", + method="POST", + path="/autoteka/v1/sync/create-by-vin", + request_model=VinRequest, + response_model=AutotekaReportInfo, + requires_auth=False, +) +ADD_MONITORING_BUCKET = OperationSpec( + name="autoteka.monitoring.bucket_add", + method="POST", + path="/autoteka/v1/monitoring/bucket/add", + request_model=MonitoringBucketRequest, + response_model=MonitoringBucketResult, + requires_auth=False, +) +DELETE_MONITORING_BUCKET = OperationSpec( + name="autoteka.monitoring.bucket_delete", + method="POST", + path="/autoteka/v1/monitoring/bucket/delete", + response_model=MonitoringBucketResult, + requires_auth=False, +) +REMOVE_MONITORING_BUCKET = OperationSpec( + name="autoteka.monitoring.bucket_remove", + method="POST", + path="/autoteka/v1/monitoring/bucket/remove", + request_model=MonitoringBucketRequest, + response_model=MonitoringBucketResult, + requires_auth=False, +) +GET_MONITORING_REG_ACTIONS = OperationSpec( + name="autoteka.monitoring.get_reg_actions", + method="GET", + path="/autoteka/v1/monitoring/get-reg-actions/", + query_model=MonitoringEventsQuery, + response_model=MonitoringEventsResult, + requires_auth=False, +) +CREATE_SCORING_BY_VEHICLE_ID = OperationSpec( + name="autoteka.scoring.create_by_vehicle_id", + method="POST", + path="/autoteka/v1/scoring/by-vehicle-id", + request_model=VehicleIdRequest, + response_model=AutotekaScoringInfo, + requires_auth=False, +) +GET_SCORING_BY_ID = OperationSpec( + name="autoteka.scoring.get_by_id", + method="GET", + path="/autoteka/v1/scoring/{scoring_id}", + response_model=AutotekaScoringInfo, + requires_auth=False, +) +CREATE_SPECIFICATION_BY_PLATE_NUMBER = OperationSpec( + name="autoteka.specification.create_by_plate_number", + method="POST", + path="/autoteka/v1/specifications/by-plate-number", + request_model=PlateNumberRequest, + response_model=AutotekaSpecificationInfo, + requires_auth=False, +) +CREATE_SPECIFICATION_BY_VEHICLE_ID = OperationSpec( + name="autoteka.specification.create_by_vehicle_id", + method="POST", + path="/autoteka/v1/specifications/by-vehicle-id", + request_model=VehicleIdRequest, + response_model=AutotekaSpecificationInfo, + requires_auth=False, +) +GET_SPECIFICATION_BY_ID = OperationSpec( + name="autoteka.specification.get_by_id", + method="GET", + path="/autoteka/v1/specifications/specification/{specificationID}", + response_model=AutotekaSpecificationInfo, + requires_auth=False, +) +CREATE_TEASER = OperationSpec( + name="autoteka.teaser.create", + method="POST", + path="/autoteka/v1/teasers", + request_model=TeaserCreateRequest, + response_model=AutotekaTeaserInfo, + requires_auth=False, +) +GET_TEASER = OperationSpec( + name="autoteka.teaser.get", + method="GET", + path="/autoteka/v1/teasers/{teaser_id}", + response_model=AutotekaTeaserInfo, + requires_auth=False, +) +GET_VALUATION_BY_SPECIFICATION = OperationSpec( + name="autoteka.valuation.by_specification", + method="POST", + path="/autoteka/v1/valuation/by-specification", + request_model=ValuationBySpecificationRequest, + response_model=AutotekaValuationInfo, + requires_auth=False, + retry_mode="enabled", +) + +__all__ = ( + "ADD_MONITORING_BUCKET", + "CATALOG_RESOLVE", + "CREATE_PREVIEW_BY_EXTERNAL_ITEM", + "CREATE_PREVIEW_BY_ITEM_ID", + "CREATE_PREVIEW_BY_REG_NUMBER", + "CREATE_PREVIEW_BY_VIN", + "CREATE_REPORT", + "CREATE_REPORT_BY_VEHICLE_ID", + "CREATE_SCORING_BY_VEHICLE_ID", + "CREATE_SPECIFICATION_BY_PLATE_NUMBER", + "CREATE_SPECIFICATION_BY_VEHICLE_ID", + "CREATE_SYNC_REPORT_BY_REG_NUMBER", + "CREATE_SYNC_REPORT_BY_VIN", + "CREATE_TEASER", + "DELETE_MONITORING_BUCKET", + "GET_ACTIVE_PACKAGE", + "GET_LEADS", + "GET_MONITORING_REG_ACTIONS", + "GET_PREVIEW", + "GET_REPORT", + "GET_SCORING_BY_ID", + "GET_SPECIFICATION_BY_ID", + "GET_TEASER", + "GET_VALUATION_BY_SPECIFICATION", + "LIST_REPORTS", + "REMOVE_MONITORING_BUCKET", +) diff --git a/avito/client.py b/avito/client.py index 6afc226..35133a7 100644 --- a/avito/client.py +++ b/avito/client.py @@ -7,12 +7,9 @@ from pathlib import Path from types import TracebackType -import httpx - from avito.accounts import Account, AccountHierarchy from avito.ads import Ad, AdPromotion, AdStats, AutoloadArchive, AutoloadProfile, AutoloadReport -from avito.ads.enums import ListingStatus -from avito.ads.models import CallStats, ListingStats, SpendingRecord +from avito.ads.models import CallStats, ListingStats, ListingStatus, SpendingRecord from avito.auth import AlternateTokenClient, AuthProvider, TokenClient from avito.autoteka import ( AutotekaMonitoring, @@ -23,13 +20,12 @@ ) from avito.config import AvitoSettings from avito.core import Transport, TransportDebugInfo -from avito.core.exceptions import AvitoError, ConfigurationError -from avito.core.transport import build_httpx_timeout +from avito.core.exceptions import AvitoError, ClientClosedError from avito.cpa import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead from avito.jobs import Application, JobDictionary, JobWebhook, Resume, Vacancy from avito.messenger import Chat, ChatMedia, ChatMessage, ChatWebhook, SpecialOfferCampaign from avito.orders import DeliveryOrder, DeliveryTask, Order, OrderLabel, SandboxDelivery, Stock -from avito.orders.enums import OrderStatus +from avito.orders.models import OrderStatus from avito.promotion import ( AutostrategyCampaign, BbipPromotion, @@ -38,7 +34,7 @@ TargetActionPricing, TrxPromotion, ) -from avito.promotion.enums import PromotionOrderServiceStatus, PromotionOrderStatus +from avito.promotion.models import PromotionOrderServiceStatus, PromotionOrderStatus from avito.ratings import RatingProfile, Review, ReviewAnswer from avito.realty import RealtyAnalyticsReport, RealtyBooking, RealtyListing, RealtyPricing from avito.summary import ( @@ -58,6 +54,16 @@ SummaryDate = date | datetime | str +def _default_summary_date_range( + date_from: SummaryDate | None, + date_to: SummaryDate | None, +) -> tuple[SummaryDate, SummaryDate]: + if date_from is not None and date_to is not None: + return date_from, date_to + today = date.today().isoformat() + return date_from or today, date_to or today + + def _sum_optional_int(values: Iterable[int | None]) -> int | None: resolved = [value for value in values if value is not None] if not resolved: @@ -118,9 +124,9 @@ 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) + self._settings = (settings or AvitoSettings.from_env()).validate_required() + self._auth_provider = self._build_auth_provider() + self._transport = Transport(self.settings, auth_provider=self.auth_provider) @classmethod def from_env(cls, *, env_file: str | Path | None = ".env") -> AvitoClient: @@ -138,11 +144,29 @@ def _from_transport( ) -> AvitoClient: client = cls.__new__(cls) client._closed = False - client.settings = settings - client.auth_provider = auth_provider - client.transport = transport + client._settings = settings + client._auth_provider = auth_provider + client._transport = transport return client + @property + def settings(self) -> AvitoSettings: + """Возвращает read-only настройки клиента.""" + + return self._settings + + @property + def auth_provider(self) -> AuthProvider: + """Возвращает read-only auth provider клиента.""" + + return self._auth_provider + + @property + def transport(self) -> Transport: + """Возвращает read-only transport клиента.""" + + return self._transport + def auth(self) -> AuthProvider: """Возвращает объект аутентификации и token-flow операций. @@ -261,15 +285,16 @@ def listing_health( spendings_by_item_id: dict[int, SpendingRecord] = {} unavailable_sections: list[SummaryUnavailableSection] = [] if item_ids: + stats_date_from, stats_date_to = _default_summary_date_range(date_from, date_to) item_stats = self.ad_stats(user_id=resolved_user_id).get_item_stats( item_ids=item_ids, - date_from=date_from, - date_to=date_to, + date_from=stats_date_from, + date_to=stats_date_to, ) calls_stats = self.ad_stats(user_id=resolved_user_id).get_calls_stats( item_ids=item_ids, - date_from=date_from, - date_to=date_to, + date_from=stats_date_from, + date_to=stats_date_to, ) stats_by_item_id = { stats.item_id: stats for stats in item_stats.items if stats.item_id is not None @@ -280,8 +305,10 @@ def listing_health( try: spendings = self.ad_stats(user_id=resolved_user_id).get_account_spendings( item_ids=item_ids, - date_from=date_from, - date_to=date_to, + date_from=stats_date_from, + date_to=stats_date_to, + spending_types=["promotion", "presence", "commission", "rest"], + grouping="day", ) except AvitoError as error: unavailable_sections.append(_summary_unavailable_section("spendings", error)) @@ -445,7 +472,9 @@ def capabilities(self) -> CapabilityDiscoveryResult: user_id_reasons = ( ["Настроен user_id или его можно получить через профиль."] if has_user_id - else ["Для части операций SDK получит user_id через профиль или потребует явный аргумент."] + else [ + "Для части операций SDK получит user_id через профиль или потребует явный аргумент." + ] ) return CapabilityDiscoveryResult( items=[ @@ -461,7 +490,9 @@ def capabilities(self) -> CapabilityDiscoveryResult: factory_method="listing_health", is_available=True, reasons=user_id_reasons - + ["400 возможен при неверном фильтре, 403 при недоступном аккаунте, 429 при лимите."], + + [ + "400 возможен при неверном фильтре, 403 при недоступном аккаунте, 429 при лимите." + ], possible_error_codes=[400, 403, 429], ), CapabilityInfo( @@ -524,35 +555,23 @@ def __exit__( self.close() def _build_auth_provider(self) -> AuthProvider: - timeout = build_httpx_timeout(self.settings.timeouts) - token_http_client = httpx.Client( - base_url=self.settings.base_url.rstrip("/"), - timeout=timeout, - ) - alternate_http_client = httpx.Client( - base_url=self.settings.base_url.rstrip("/"), - timeout=timeout, - ) - autoteka_http_client = httpx.Client( - base_url=self.settings.base_url.rstrip("/"), - timeout=timeout, - ) return AuthProvider( self.settings.auth, - token_client=TokenClient(self.settings.auth, client=token_http_client), + token_client=TokenClient(self.settings.auth, sdk_settings=self.settings), alternate_token_client=AlternateTokenClient( - self.settings.auth, client=alternate_http_client + self.settings.auth, + sdk_settings=self.settings, ), autoteka_token_client=TokenClient( self.settings.auth, token_url=self.settings.auth.autoteka_token_url, - client=autoteka_http_client, + sdk_settings=self.settings, ), ) def _ensure_open(self) -> None: if self._closed: - raise ConfigurationError("Клиент закрыт; создайте новый AvitoClient.") + raise ClientClosedError("Клиент закрыт; создайте новый AvitoClient.") def _require_transport(self) -> Transport: self._ensure_open() diff --git a/avito/config.py b/avito/config.py index 85251ee..10e924f 100644 --- a/avito/config.py +++ b/avito/config.py @@ -84,7 +84,9 @@ def _validate_user_agent_suffix(self) -> None: if not suffix.strip(): raise ConfigurationError("Поле `user_agent_suffix` не должно быть пустым.") if "\r" in suffix or "\n" in suffix: - raise ConfigurationError("Поле `user_agent_suffix` не должно содержать переводы строки.") + raise ConfigurationError( + "Поле `user_agent_suffix` не должно содержать переводы строки." + ) lowered = suffix.lower() if any(fragment in lowered for fragment in _FORBIDDEN_USER_AGENT_SUFFIX_FRAGMENTS): raise ConfigurationError( diff --git a/avito/core/__init__.py b/avito/core/__init__.py index 6c30051..0f3ee50 100644 --- a/avito/core/__init__.py +++ b/avito/core/__init__.py @@ -5,19 +5,21 @@ AuthenticationError, AuthorizationError, AvitoError, - ClientError, + ClientClosedError, ConfigurationError, ConflictError, - NotFoundError, RateLimitError, ResponseMappingError, - ServerError, TransportError, UnsupportedOperationError, UpstreamApiError, ValidationError, ) +from avito.core.fields import api_field +from avito.core.models import ApiErrorPayload, ApiModel, EmptyRequest, RequestModel +from avito.core.operations import EmptyResponse, OperationExecutor, OperationSpec from avito.core.pagination import PaginatedList, Paginator +from avito.core.payload import JsonReader from avito.core.retries import RetryDecision, RetryPolicy from avito.core.serialization import SerializableModel from avito.core.swagger import SwaggerOperationBinding, swagger_operation @@ -27,30 +29,38 @@ BinaryResponse, JsonPage, RequestContext, + RetryOverride, TransportDebugInfo, ) __all__ = ( "ApiTimeouts", + "ApiModel", + "ApiErrorPayload", "AuthenticationError", "AuthorizationError", "AvitoError", "BinaryResponse", - "ClientError", + "ClientClosedError", "ConfigurationError", "ConflictError", "DomainObject", + "EmptyResponse", + "EmptyRequest", + "JsonReader", "JsonPage", - "NotFoundError", + "OperationExecutor", + "OperationSpec", "PaginatedList", "Paginator", "RateLimitError", "RequestContext", + "RequestModel", "ResponseMappingError", "RetryDecision", + "RetryOverride", "RetryPolicy", "SerializableModel", - "ServerError", "SwaggerOperationBinding", "Transport", "TransportDebugInfo", @@ -58,5 +68,6 @@ "UnsupportedOperationError", "UpstreamApiError", "ValidationError", + "api_field", "swagger_operation", ) diff --git a/avito/core/domain.py b/avito/core/domain.py index 813262c..f2712a7 100644 --- a/avito/core/domain.py +++ b/avito/core/domain.py @@ -2,14 +2,19 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeVar from avito.core.exceptions import ValidationError +from avito.core.operations import OperationExecutor, OperationSpec +from avito.core.types import ApiTimeouts, RequestContext, RetryOverride if TYPE_CHECKING: from avito.core.transport import Transport +ResponseT = TypeVar("ResponseT") + @dataclass(slots=True, frozen=True) class DomainObject: @@ -17,6 +22,35 @@ class DomainObject: transport: Transport + def _execute( + self, + spec: OperationSpec[ResponseT], + *, + path_params: Mapping[str, object] | None = None, + query: object | Mapping[str, object] | None = None, + request: object | Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, + idempotency_key: str | None = None, + data: Mapping[str, object] | None = None, + files: Mapping[str, object] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ResponseT: + """Выполняет v2 operation spec через общий executor.""" + + return OperationExecutor(self.transport).execute( + spec, + path_params=path_params, + query=query, + request=request, + headers=headers, + idempotency_key=idempotency_key, + data=data, + files=files, + timeout=timeout, + retry=retry, + ) + def _resolve_user_id(self, user_id: int | str | None = None) -> int: """Возвращает user_id из аргумента, настроек SDK или профиля текущего пользователя.""" @@ -27,15 +61,30 @@ def _resolve_user_id(self, user_id: int | str | None = None) -> int: if configured_user_id is not None: return configured_user_id - from avito.accounts.client import AccountsClient - - profile = AccountsClient(self.transport).get_self() - if profile.user_id is None: + payload = self.transport.request_json( + "GET", + "/core/v1/accounts/self", + context=RequestContext("accounts.resolve_user_id"), + ) + resolved_user_id = _extract_user_id(payload) + if resolved_user_id is None: raise ValidationError( "Для операции требуется `user_id`: передайте его в фабрику клиента, " "в метод операции или задайте `AVITO_USER_ID`." ) - return profile.user_id + return resolved_user_id + + +def _extract_user_id(payload: object) -> int | None: + if not isinstance(payload, dict): + return None + for key in ("id", "user_id", "userId"): + value = payload.get(key) + if isinstance(value, bool): + continue + if isinstance(value, int): + return value + return None __all__ = ("DomainObject",) diff --git a/avito/core/enums.py b/avito/core/enums.py index 828e88b..b43c8bc 100644 --- a/avito/core/enums.py +++ b/avito/core/enums.py @@ -3,13 +3,15 @@ from __future__ import annotations import logging -from enum import Enum +from enum import Enum, IntEnum logger = logging.getLogger(__name__) -_warned_unknown_enum_values: set[tuple[str, str]] = set() +_warned_unknown_enum_values: set[tuple[str, object]] = set() -def map_enum_or_unknown[T: Enum](value: str | None, enum_type: type[T], *, enum_name: str) -> T | None: +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: @@ -27,4 +29,24 @@ def map_enum_or_unknown[T: Enum](value: str | None, enum_type: type[T], *, enum_ return enum_type.__members__["UNKNOWN"] -__all__ = ("map_enum_or_unknown",) +def map_int_enum_or_unknown[T: IntEnum]( + value: int | 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", "map_int_enum_or_unknown") diff --git a/avito/core/exceptions.py b/avito/core/exceptions.py index 330f0b4..4a72e61 100644 --- a/avito/core/exceptions.py +++ b/avito/core/exceptions.py @@ -48,6 +48,9 @@ class AvitoError(Exception): status_code: int | None = None error_code: str | None = None operation: str | None = None + attempt: int | None = None + method: str | None = None + endpoint: str | None = None details: object | None = None retry_after: float | None = None request_id: str | None = None @@ -78,6 +81,12 @@ def __str__(self) -> str: details: list[str] = [self.message] if self.operation is not None: details.append(f"operation={self.operation}") + if self.attempt is not None: + details.append(f"attempt={self.attempt}") + if self.method is not None: + details.append(f"method={self.method}") + if self.endpoint is not None: + details.append(f"endpoint={self.endpoint}") if self.status_code is not None: details.append(f"status={self.status_code}") if self.error_code is not None: @@ -109,6 +118,10 @@ class ConfigurationError(AvitoError): """SDK сконфигурирован некорректно — ошибка обнаружена до выполнения HTTP-запроса.""" +class ClientClosedError(AvitoError): + """Вызов выполнен после закрытия `AvitoClient`.""" + + class RateLimitError(AvitoError): """Превышен лимит запросов API (HTTP 429).""" @@ -125,18 +138,6 @@ class UpstreamApiError(AvitoError): """Неизвестная ошибка upstream API вне специализированных типов SDK.""" -class NotFoundError(UpstreamApiError): - """Запрошенный ресурс не найден (HTTP 404).""" - - -class ClientError(UpstreamApiError): - """Прочая клиентская ошибка диапазона 4xx без более конкретного типа.""" - - -class ServerError(UpstreamApiError): - """Серверная ошибка диапазона 5xx.""" - - class ResponseMappingError(AvitoError): """Не удалось безопасно преобразовать ответ API в ожидаемый тип.""" @@ -145,13 +146,11 @@ class ResponseMappingError(AvitoError): "AuthenticationError", "AuthorizationError", "AvitoError", - "ClientError", + "ClientClosedError", "ConfigurationError", "ConflictError", - "NotFoundError", "RateLimitError", "ResponseMappingError", - "ServerError", "TransportError", "UnsupportedOperationError", "UpstreamApiError", diff --git a/avito/core/fields.py b/avito/core/fields.py new file mode 100644 index 0000000..317c8b6 --- /dev/null +++ b/avito/core/fields.py @@ -0,0 +1,47 @@ +"""Metadata helpers for API field names in request and query models.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import MISSING, Field, field +from typing import TypeVar, cast + +_FieldT = TypeVar("_FieldT") +API_FIELD_METADATA_KEY = "api_name" + + +def api_field( + api_name: str, + *, + default: _FieldT | object = MISSING, + default_factory: Callable[[], _FieldT] | object = MISSING, +) -> Field[_FieldT]: + """Create dataclass field metadata for an upstream API field name.""" + + if default is not MISSING and default_factory is not MISSING: + raise TypeError("Нельзя одновременно передавать default и default_factory.") + metadata = {API_FIELD_METADATA_KEY: api_name} + if default_factory is not MISSING: + return cast( + Field[_FieldT], + field( + default_factory=cast(Callable[[], _FieldT], default_factory), + metadata=metadata, + ), + ) + if default is not MISSING: + return cast(Field[_FieldT], field(default=cast(_FieldT, default), metadata=metadata)) + return cast(Field[_FieldT], field(metadata=metadata)) + + +def get_api_field_name(field_name: str, metadata: object) -> str: + """Return API field name from dataclass metadata or the Python field name.""" + + if isinstance(metadata, Mapping): + value = metadata.get(API_FIELD_METADATA_KEY) + if isinstance(value, str) and value: + return value + return field_name + + +__all__ = ("API_FIELD_METADATA_KEY", "api_field", "get_api_field_name") diff --git a/avito/core/mapping.py b/avito/core/mapping.py deleted file mode 100644 index 45439fb..0000000 --- a/avito/core/mapping.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Внутренние helper-ы для преобразования transport payload в публичные SDK-модели.""" - -from __future__ import annotations - -from collections.abc import Callable, Mapping - -from avito.core.transport import Transport -from avito.core.types import HttpMethod, RequestContext - - -def request_public_model[ModelT]( - transport: Transport, - method: HttpMethod, - path: str, - *, - context: RequestContext, - mapper: Callable[[object], ModelT], - params: Mapping[str, object] | None = None, - json_body: Mapping[str, object] | None = None, - headers: Mapping[str, str] | None = None, - idempotency_key: str | None = None, -) -> ModelT: - """Выполняет HTTP-запрос и маппит JSON в публичную модель SDK.""" - - return transport.request_public_model( - method, - path, - context=context, - mapper=mapper, - params=params, - json_body=json_body, - headers=headers, - idempotency_key=idempotency_key, - ) - - -__all__ = ("request_public_model",) diff --git a/avito/core/models.py b/avito/core/models.py new file mode 100644 index 0000000..ae29030 --- /dev/null +++ b/avito/core/models.py @@ -0,0 +1,109 @@ +"""Base model classes and serializers for v2 domain architecture.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, fields, is_dataclass +from datetime import date, datetime +from enum import Enum + +from avito.core.fields import get_api_field_name +from avito.core.serialization import SerializableModel + +JsonValue = Mapping[str, object] | list[object] | str | int | float | bool | None + + +class ApiModel(SerializableModel): + """Base class for public typed API response models.""" + + +@dataclass(slots=True, frozen=True) +class ApiErrorPayload(ApiModel): + """Typed wrapper for Swagger-declared error response bodies.""" + + payload: object + + @classmethod + def from_payload(cls, payload: object) -> ApiErrorPayload: + """Preserve the upstream error payload for typed exception mapping.""" + + return cls(payload=payload) + + def to_dict(self) -> dict[str, object]: + """Return a JSON-compatible diagnostic representation.""" + + return {"payload": serialize_api_value(self.payload)} + + def model_dump(self) -> dict[str, object]: + """Compatible alias for pydantic-like public contract.""" + + return self.to_dict() + + +class RequestModel: + """Base class for request and query dataclasses with API-name serialization.""" + + def to_payload(self) -> object: + """Serialize dataclass fields into JSON-compatible request body.""" + + return _serialize_dataclass(self) + + def to_params(self) -> dict[str, object]: + """Serialize dataclass fields into query params.""" + + return _serialize_dataclass(self) + + +@dataclass(slots=True, frozen=True) +class EmptyRequest(RequestModel): + """Explicit empty JSON request body for Swagger operations that declare one.""" + + def to_payload(self) -> dict[str, object]: + """Serialize to an empty JSON object.""" + + return {} + + +def serialize_api_value(value: object) -> object: + """Serialize one request/query value into a JSON-compatible structure.""" + + if isinstance(value, Enum): + return value.value + if isinstance(value, datetime | date): + return value.isoformat() + if isinstance(value, RequestModel): + return value.to_payload() + if isinstance(value, SerializableModel): + return value.to_dict() + if is_dataclass(value): + return _serialize_dataclass(value) + if isinstance(value, Mapping): + return { + str(key): serialize_api_value(item) for key, item in value.items() if item is not None + } + if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): + return [serialize_api_value(item) for item in value if item is not None] + return value + + +def _serialize_dataclass(instance: object) -> dict[str, object]: + if not is_dataclass(instance): + raise TypeError("RequestModel supports dataclass instances only.") + payload: dict[str, object] = {} + for item in fields(instance): + value = getattr(instance, item.name) + if value is None or item.name.startswith("_") or item.name == "raw_payload": + continue + api_name = get_api_field_name(item.name, item.metadata) + payload[api_name] = serialize_api_value(value) + return payload + + +__all__ = ( + "ApiErrorPayload", + "ApiModel", + "EmptyRequest", + "JsonValue", + "RequestModel", + "serialize_api_value", +) diff --git a/avito/core/operations.py b/avito/core/operations.py new file mode 100644 index 0000000..30d9287 --- /dev/null +++ b/avito/core/operations.py @@ -0,0 +1,303 @@ +"""Operation specifications and executor for v2 domain architecture.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from email.message import Message +from typing import Literal, Protocol, TypeVar, cast +from urllib.parse import quote + +import httpx + +from avito.core.models import ApiErrorPayload, RequestModel +from avito.core.types import ApiTimeouts, BinaryResponse, HttpMethod, RequestContext, RetryOverride + +ResponseKind = Literal["json", "empty", "binary"] +RetryMode = Literal["default", "enabled", "disabled"] +ModelT = TypeVar("ModelT", covariant=True) +ResponseT = TypeVar("ResponseT", covariant=True) +DEFAULT_ERROR_MODELS: Mapping[str, type[ResponseModel[object]]] = { + "400": ApiErrorPayload, + "401": ApiErrorPayload, + "402": ApiErrorPayload, + "403": ApiErrorPayload, + "404": ApiErrorPayload, + "409": ApiErrorPayload, + "422": ApiErrorPayload, + "425": ApiErrorPayload, + "429": ApiErrorPayload, + "500": ApiErrorPayload, + "503": ApiErrorPayload, + "default": ApiErrorPayload, +} + + +class ResponseModel(Protocol[ModelT]): + """Protocol for response model classes parsed from JSON payloads.""" + + @classmethod + def from_payload(cls, payload: object) -> ModelT: + """Build response model from raw JSON payload.""" + + +class ParamsModel(Protocol): + """Protocol for query models.""" + + def to_params(self) -> Mapping[str, object]: + """Serialize model to query params.""" + + +class PayloadModel(Protocol): + """Protocol for request models.""" + + def to_payload(self) -> object: + """Serialize model to JSON payload.""" + + +class OperationTransport(Protocol): + """Transport methods required by the operation executor.""" + + def request( + self, + method: HttpMethod, + path: str, + *, + context: RequestContext, + params: Mapping[str, object] | None = None, + json_body: object | None = None, + data: Mapping[str, object] | None = None, + files: Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, + content: bytes | None = None, + idempotency_key: str | None = None, + ) -> httpx.Response: + """Execute raw request and return response object.""" + + def request_json( + self, + method: HttpMethod, + path: str, + *, + context: RequestContext, + params: Mapping[str, object] | None = None, + json_body: object | None = None, + data: Mapping[str, object] | None = None, + files: Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, + idempotency_key: str | None = None, + ) -> object: + """Execute request and return decoded JSON payload.""" + + +@dataclass(slots=True, frozen=True) +class EmptyResponse: + """Typed result for successful operations without response body.""" + + status_code: int + headers: Mapping[str, str] = field(default_factory=dict) + + +@dataclass(slots=True, frozen=True) +class OperationSpec[ResponseT]: + """HTTP contract metadata for a single SDK operation.""" + + name: str + method: HttpMethod + path: str + query_model: type[object] | None = None + request_model: type[object] | None = None + response_model: type[ResponseModel[ResponseT]] | None = None + error_models: Mapping[str, type[ResponseModel[object]]] = field( + default_factory=lambda: DEFAULT_ERROR_MODELS + ) + response_kind: ResponseKind = "json" + content_type: str | None = None + requires_auth: bool = True + retry_mode: RetryMode = "default" + + +class OperationExecutor: + """Execute operation specs through the shared transport layer.""" + + def __init__(self, transport: OperationTransport) -> None: + self._transport = transport + + def execute( + self, + spec: OperationSpec[ResponseT], + *, + path_params: Mapping[str, object] | None = None, + query: object | Mapping[str, object] | None = None, + request: object | Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, + idempotency_key: str | None = None, + data: Mapping[str, object] | None = None, + files: Mapping[str, object] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ResponseT: + """Execute operation spec and return typed response object.""" + + path = render_path(spec.path, path_params or {}) + params = _serialize_query(spec, query) + json_body = _serialize_request(spec, request) + request_headers = _merge_content_type(headers, spec.content_type) + effective_retry = spec.retry_mode if retry is None or retry == "default" else retry + context = RequestContext( + operation_name=spec.name, + allow_retry=effective_retry == "enabled", + retry_disabled=effective_retry == "disabled", + requires_auth=spec.requires_auth, + timeout=timeout, + ) + + if spec.response_kind == "binary": + return cast( + ResponseT, + _request_binary( + self._transport, + spec=spec, + path=path, + context=context, + params=params, + headers=request_headers, + idempotency_key=idempotency_key, + ), + ) + if spec.response_kind == "empty": + response = self._transport.request( + spec.method, + path, + context=context, + params=params, + json_body=json_body, + data=data, + files=files, + headers=request_headers, + idempotency_key=idempotency_key, + ) + return cast( + ResponseT, + EmptyResponse( + status_code=response.status_code, + headers=dict(response.headers), + ), + ) + + payload = self._transport.request_json( + spec.method, + path, + context=context, + params=params, + json_body=json_body, + data=data, + files=files, + headers=request_headers, + idempotency_key=idempotency_key, + ) + if spec.response_model is None: + return cast(ResponseT, payload) + return spec.response_model.from_payload(payload) + + +def render_path(path_template: str, path_params: Mapping[str, object]) -> str: + """Render operation path and percent-encode path parameter values.""" + + path = path_template + for key, value in path_params.items(): + path = path.replace("{" + key + "}", quote(str(value), safe="")) + return path + + +def _serialize_query[SpecResponseT]( + spec: OperationSpec[SpecResponseT], + query: object | Mapping[str, object] | None, +) -> Mapping[str, object] | None: + if query is None: + return None + if isinstance(query, RequestModel): + return query.to_params() + if spec.query_model is not None and isinstance(query, spec.query_model): + return cast(ParamsModel, query).to_params() + if isinstance(query, Mapping): + return query + raise TypeError("Query object не поддерживает сериализацию.") + + +def _serialize_request[SpecResponseT]( + spec: OperationSpec[SpecResponseT], + request: object | Mapping[str, object] | None, +) -> object | None: + if request is None: + return None + if isinstance(request, RequestModel): + return request.to_payload() + if spec.request_model is not None and isinstance(request, spec.request_model): + return cast(PayloadModel, request).to_payload() + if isinstance(request, Mapping): + return request + raise TypeError("Request object не поддерживает сериализацию.") + + +def _merge_content_type( + headers: Mapping[str, str] | None, + content_type: str | None, +) -> Mapping[str, str] | None: + if content_type is None: + return headers + merged = dict(headers or {}) + merged.setdefault("Content-Type", content_type) + return merged + + +def _request_binary[SpecResponseT]( + transport: OperationTransport, + *, + spec: OperationSpec[SpecResponseT], + path: str, + context: RequestContext, + params: Mapping[str, object] | None, + headers: Mapping[str, str] | None, + idempotency_key: str | None, +) -> BinaryResponse: + response = transport.request( + spec.method, + path, + context=context, + params=params, + headers=headers, + idempotency_key=idempotency_key, + ) + return BinaryResponse( + content=response.content, + content_type=response.headers.get("content-type"), + filename=_extract_filename(response.headers.get("content-disposition")), + status_code=response.status_code, + headers=dict(response.headers), + ) + + +def _extract_filename(content_disposition: str | None) -> str | None: + if content_disposition is None: + return None + message = Message() + message["content-disposition"] = content_disposition + filename = message.get_param("filename", header="content-disposition") + if isinstance(filename, tuple): + _, _, decoded_value = filename + return decoded_value + return filename + + +__all__ = ( + "EmptyResponse", + "OperationExecutor", + "OperationSpec", + "OperationTransport", + "DEFAULT_ERROR_MODELS", + "ResponseKind", + "ResponseModel", + "RetryMode", + "render_path", +) diff --git a/avito/core/payload.py b/avito/core/payload.py new file mode 100644 index 0000000..9788e97 --- /dev/null +++ b/avito/core/payload.py @@ -0,0 +1,204 @@ +"""Safe JSON payload readers for response model parsing.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import datetime +from enum import Enum +from typing import TypeVar, cast + +from avito.core.exceptions import ResponseMappingError + +PayloadMapping = Mapping[str, object] +EnumT = TypeVar("EnumT", bound=Enum) + + +class JsonReader: + """Typed helpers for reading upstream JSON payloads.""" + + def __init__(self, payload: object) -> None: + self._payload = self.expect_mapping(payload) + + @staticmethod + def expect_mapping(payload: object) -> PayloadMapping: + """Return payload as mapping or raise mapping error.""" + + if not isinstance(payload, Mapping): + raise ResponseMappingError("Ожидался JSON-объект.", payload=payload) + return cast(PayloadMapping, payload) + + @staticmethod + def expect_list(payload: object) -> list[object]: + """Return payload as list or raise mapping error.""" + + if not isinstance(payload, list): + raise ResponseMappingError("Ожидался JSON-массив.", payload=payload) + return payload + + def required_str(self, *keys: str) -> str: + """Read required string field using fallback key order.""" + + value = self.optional_str(*keys) + if value is None: + raise self._missing_required(keys) + return value + + def optional_str(self, *keys: str) -> str | None: + """Read optional string field using fallback key order.""" + + value = self._first_present(keys) + if value is None: + return None + if isinstance(value, str): + return value + raise self._wrong_type(keys, "строка") + + def required_int(self, *keys: str) -> int: + """Read required integer field using fallback key order.""" + + value = self.optional_int(*keys) + if value is None: + raise self._missing_required(keys) + return value + + def optional_int(self, *keys: str) -> int | None: + """Read optional integer field using fallback key order.""" + + value = self._first_present(keys) + if value is None: + return None + if isinstance(value, bool): + raise self._wrong_type(keys, "целое число") + if isinstance(value, int): + return value + raise self._wrong_type(keys, "целое число") + + def required_float(self, *keys: str) -> float: + """Read required numeric field using fallback key order.""" + + value = self.optional_float(*keys) + if value is None: + raise self._missing_required(keys) + return value + + def optional_float(self, *keys: str) -> float | None: + """Read optional numeric field using fallback key order.""" + + value = self._first_present(keys) + if value is None: + return None + if isinstance(value, bool): + raise self._wrong_type(keys, "число") + if isinstance(value, int | float): + return float(value) + raise self._wrong_type(keys, "число") + + def required_bool(self, *keys: str) -> bool: + """Read required boolean field using fallback key order.""" + + value = self.optional_bool(*keys) + if value is None: + raise self._missing_required(keys) + return value + + def optional_bool(self, *keys: str) -> bool | None: + """Read optional boolean field using fallback key order.""" + + value = self._first_present(keys) + if value is None: + return None + if isinstance(value, bool): + return value + raise self._wrong_type(keys, "boolean") + + def required_datetime(self, *keys: str) -> datetime: + """Read required ISO datetime field using fallback key order.""" + + value = self.optional_datetime(*keys) + if value is None: + raise self._missing_required(keys) + return value + + def optional_datetime(self, *keys: str) -> datetime | None: + """Read optional ISO datetime field using fallback key order.""" + + raw_value = self.optional_str(*keys) + if raw_value is None: + return None + try: + return datetime.fromisoformat(raw_value.replace("Z", "+00:00")) + except ValueError as exc: + raise ResponseMappingError( + f"Поле `{_field_label(keys)}` содержит некорректную дату.", + payload=self._payload, + ) from exc + + def enum( + self, + enum_type: type[EnumT], + *keys: str, + unknown: EnumT | None = None, + ) -> EnumT | None: + """Read enum value; return unknown fallback when provided.""" + + raw_value = self.optional_str(*keys) + if raw_value is None: + return None + try: + return enum_type(raw_value) + except ValueError: + if unknown is not None: + return unknown + raise ResponseMappingError( + f"Поле `{_field_label(keys)}` содержит неизвестное значение enum.", + payload=self._payload, + ) from None + + def mapping(self, *keys: str) -> PayloadMapping | None: + """Read optional nested mapping.""" + + value = self._first_present(keys) + if value is None: + return None + if isinstance(value, Mapping): + return cast(PayloadMapping, value) + raise self._wrong_type(keys, "JSON-объект") + + def list(self, *keys: str) -> list[object]: + """Read optional list field and return an empty list when missing.""" + + value = self._first_present(keys) + if value is None: + return [] + if isinstance(value, list): + return value + if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): + return list(value) + raise self._wrong_type(keys, "JSON-массив") + + def _first_present(self, keys: tuple[str, ...]) -> object | None: + for key in keys: + if key in self._payload and self._payload[key] is not None: + return self._payload[key] + return None + + def _missing_required(self, keys: tuple[str, ...]) -> ResponseMappingError: + return ResponseMappingError( + f"В ответе API отсутствует обязательное поле `{_field_label(keys)}`.", + payload=self._payload, + ) + + def _wrong_type(self, keys: tuple[str, ...], expected: str) -> ResponseMappingError: + return ResponseMappingError( + f"Поле `{_field_label(keys)}` должно иметь тип: {expected}.", + payload=self._payload, + ) + + +def _field_label(keys: tuple[str, ...]) -> str: + if not keys: + return "" + return "`, `".join(keys) + + +__all__ = ("JsonReader", "PayloadMapping") diff --git a/avito/core/swagger.py b/avito/core/swagger.py index 2f36b0f..5bd22ea 100644 --- a/avito/core/swagger.py +++ b/avito/core/swagger.py @@ -91,9 +91,7 @@ def swagger_operation( def decorate(func: Callable[P, R]) -> Callable[P, R]: if hasattr(func, "__swagger_binding__") or hasattr(func, "__swagger_bindings__"): - raise ConfigurationError( - "Несколько Swagger binding-ов на одном SDK method запрещены." - ) + raise ConfigurationError("Несколько Swagger binding-ов на одном SDK method запрещены.") func.__swagger_binding__ = binding # type: ignore[attr-defined] return func diff --git a/avito/core/swagger_linter.py b/avito/core/swagger_linter.py index cadf488..5c42839 100644 --- a/avito/core/swagger_linter.py +++ b/avito/core/swagger_linter.py @@ -2,17 +2,43 @@ from __future__ import annotations +import ast import importlib import inspect +import pkgutil +import textwrap from collections import defaultdict from collections.abc import Callable, Mapping, Sequence +from types import ModuleType from avito.client import AvitoClient from avito.core.deprecation import DeprecatedSdkSymbol +from avito.core.operations import OperationSpec from avito.core.swagger_discovery import DiscoveredSwaggerBinding, SwaggerBindingDiscovery -from avito.core.swagger_registry import SwaggerOperation, SwaggerRegistry +from avito.core.swagger_registry import ( + SwaggerOperation, + SwaggerRegistry, + normalize_swagger_method, + normalize_swagger_path, +) from avito.core.swagger_report import SwaggerReportError +from avito.core.swagger_schema_paths import SwaggerSchemaPathError, resolve_body_path +_API_DOMAINS = frozenset( + { + "accounts", + "ads", + "autoteka", + "cpa", + "jobs", + "messenger", + "orders", + "promotion", + "ratings", + "realty", + "tariffs", + } +) _TEST_CONSTANTS = frozenset( { "account_id", @@ -57,6 +83,8 @@ def lint_swagger_bindings( errors.extend(_validate_duplicate_bindings(discovery.bindings)) if strict: errors.extend(_validate_complete_bindings(registry.operations, discovery.bindings)) + errors.extend(_validate_operation_spec_coverage(registry, discovery.bindings)) + errors.extend(_validate_json_body_model_coverage(registry, discovery.bindings)) for binding in discovery.bindings: operation = _resolve_bound_operation( binding=binding, @@ -74,6 +102,215 @@ def lint_swagger_bindings( return tuple(errors) +def _validate_operation_spec_coverage( + registry: SwaggerRegistry, + bindings: Sequence[DiscoveredSwaggerBinding], +) -> tuple[SwaggerReportError, ...]: + operations_by_key = {operation.key: operation for operation in registry.operations} + used_specs: set[int] = set() + errors: list[SwaggerReportError] = [] + + for binding in bindings: + if binding.domain not in _API_DOMAINS: + continue + operation = operations_by_key.get(binding.operation_key or "") + sdk_method = _load_sdk_method(binding) + specs = _operation_specs_for_sdk_method(sdk_method) + if len(specs) != 1: + errors.append( + SwaggerReportError( + code="SWAGGER_OPERATION_SPEC_MISSING", + message=( + f"{binding.sdk_method}: public API method должен исполнять " + "ровно один OperationSpec через `_execute(...)`." + ), + operation_key=binding.operation_key, + sdk_method=binding.sdk_method, + ) + ) + continue + + spec = specs[0] + used_specs.add(id(spec)) + if operation is not None: + errors.extend(_validate_operation_spec_matches_binding(binding, operation, spec)) + + errors.extend(_validate_no_unbound_operation_specs(used_specs)) + return tuple(errors) + + +def _validate_operation_spec_matches_binding( + binding: DiscoveredSwaggerBinding, + operation: SwaggerOperation, + spec: OperationSpec[object], +) -> tuple[SwaggerReportError, ...]: + errors: list[SwaggerReportError] = [] + if normalize_swagger_method(spec.method) != operation.method: + errors.append( + SwaggerReportError( + code="SWAGGER_OPERATION_SPEC_METHOD_MISMATCH", + message=( + f"{binding.sdk_method}: OperationSpec method `{spec.method}` " + f"не совпадает со Swagger method `{operation.method}`." + ), + operation_key=operation.key, + sdk_method=binding.sdk_method, + ) + ) + if normalize_swagger_path(spec.path) != operation.path: + errors.append( + SwaggerReportError( + code="SWAGGER_OPERATION_SPEC_PATH_MISMATCH", + message=( + f"{binding.sdk_method}: OperationSpec path `{spec.path}` " + f"не совпадает со Swagger path `{operation.path}`." + ), + operation_key=operation.key, + sdk_method=binding.sdk_method, + ) + ) + return tuple(errors) + + +def _validate_json_body_model_coverage( + registry: SwaggerRegistry, + bindings: Sequence[DiscoveredSwaggerBinding], +) -> tuple[SwaggerReportError, ...]: + operations_by_key = {operation.key: operation for operation in registry.operations} + errors: list[SwaggerReportError] = [] + + for binding in bindings: + if binding.domain not in _API_DOMAINS: + continue + operation = operations_by_key.get(binding.operation_key or "") + if operation is None: + continue + specs = _operation_specs_for_sdk_method(_load_sdk_method(binding)) + if len(specs) != 1: + continue + errors.extend( + _validate_operation_json_body_models( + binding=binding, + operation=operation, + spec=specs[0], + ) + ) + + return tuple(errors) + + +def _validate_operation_json_body_models( + *, + binding: DiscoveredSwaggerBinding, + operation: SwaggerOperation, + spec: OperationSpec[object], +) -> tuple[SwaggerReportError, ...]: + errors: list[SwaggerReportError] = [] + request_body = operation.request_body + if request_body is not None and _has_json_content(request_body.content_types): + if request_body.schema is None: + errors.append( + _contract_error( + binding=binding, + code="SWAGGER_CONTRACT_REQUEST_SCHEMA_UNPARSED", + message=f"{operation.key}: requestBody schema не разобрана.", + ) + ) + if spec.request_model is None: + errors.append( + _contract_error( + binding=binding, + code="SWAGGER_CONTRACT_REQUEST_MODEL_MISSING", + message=f"{operation.key}: {spec.name} без request_model.", + ) + ) + + for response in operation.success_responses: + if not _has_json_content(response.content_types): + continue + if response.schema is None: + errors.append( + _contract_error( + binding=binding, + code="SWAGGER_CONTRACT_RESPONSE_SCHEMA_UNPARSED", + message=( + f"{operation.key} {response.status_code}: " + "response schema не разобрана." + ), + ) + ) + if spec.response_kind == "json" and spec.response_model is None: + errors.append( + _contract_error( + binding=binding, + code="SWAGGER_CONTRACT_RESPONSE_MODEL_MISSING", + message=f"{operation.key} {response.status_code}: {spec.name} без response_model.", + ) + ) + + for response in operation.error_responses: + if not _has_json_content(response.content_types): + continue + if response.schema is None: + errors.append( + _contract_error( + binding=binding, + code="SWAGGER_CONTRACT_ERROR_SCHEMA_UNPARSED", + message=( + f"{operation.key} {response.status_code}: " + "error schema не разобрана." + ), + ) + ) + if response.status_code not in spec.error_models: + errors.append( + _contract_error( + binding=binding, + code="SWAGGER_CONTRACT_ERROR_MODEL_MISSING", + message=f"{operation.key} {response.status_code}: {spec.name} без error_model.", + ) + ) + + return tuple(errors) + + +def _has_json_content(content_types: Sequence[str]) -> bool: + return any("application/json" in content_type for content_type in content_types) + + +def _contract_error( + *, + binding: DiscoveredSwaggerBinding, + code: str, + message: str, +) -> SwaggerReportError: + return SwaggerReportError( + code=code, + message=message, + operation_key=binding.operation_key, + sdk_method=binding.sdk_method, + ) + + +def _validate_no_unbound_operation_specs(used_specs: set[int]) -> tuple[SwaggerReportError, ...]: + errors: list[SwaggerReportError] = [] + for module_name, spec_name, spec in _iter_api_domain_operation_specs(): + if id(spec) in used_specs: + continue + errors.append( + SwaggerReportError( + code="SWAGGER_OPERATION_SPEC_UNBOUND", + message=( + f"{module_name}.{spec_name}: OperationSpec не связан с публичным " + "Swagger binding." + ), + operation_key=None, + sdk_method=None, + ) + ) + return tuple(errors) + + def _validate_legacy_stacked_binding_metadata( discovery: SwaggerBindingDiscovery, ) -> tuple[SwaggerReportError, ...]: @@ -338,6 +575,84 @@ def _validate_sdk_method_signature( ) +def _operation_specs_for_sdk_method( + sdk_method: Callable[..., object] | None, +) -> tuple[OperationSpec[object], ...]: + if sdk_method is None: + return () + unwrapped_method = inspect.unwrap(sdk_method) + try: + source = inspect.getsource(unwrapped_method) + except (OSError, TypeError): + return () + tree = ast.parse(textwrap.dedent(source)) + specs: list[OperationSpec[object]] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not _is_execute_call(node): + continue + if not node.args: + continue + spec_name = _name(node.args[0]) + if spec_name is None: + continue + spec = unwrapped_method.__globals__.get(spec_name) + if isinstance(spec, OperationSpec): + specs.append(spec) + return tuple(specs) + + +def _iter_api_domain_operation_specs() -> tuple[tuple[str, str, OperationSpec[object]], ...]: + specs: list[tuple[str, str, OperationSpec[object]]] = [] + for domain in sorted(_API_DOMAINS): + for module in _iter_domain_operation_modules(domain): + for name, value in vars(module).items(): + if isinstance(value, OperationSpec): + specs.append((module.__name__, name, value)) + return tuple(specs) + + +def _iter_domain_operation_modules(domain: str) -> tuple[ModuleType, ...]: + root_module_name = f"avito.{domain}.operations" + module = importlib.import_module(root_module_name) + modules: list[ModuleType] = [module] + module_path = getattr(module, "__path__", None) + if module_path is None: + return tuple(modules) + for info in pkgutil.walk_packages(module_path, prefix=f"{root_module_name}."): + modules.append(importlib.import_module(info.name)) + return tuple(modules) + + +def _is_execute_call(node: ast.Call) -> bool: + name = _call_name(node.func) + return name in {"self._execute", "_execute"} or name.endswith("._execute") + + +def _name(node: ast.AST) -> str | None: + if isinstance(node, ast.Name): + return node.id + return None + + +def _call_name(node: ast.AST) -> str: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return _attribute_name(node) + return "" + + +def _attribute_name(node: ast.Attribute) -> str: + parts = [node.attr] + value = node.value + while isinstance(value, ast.Attribute): + parts.append(value.attr) + value = value.value + if isinstance(value, ast.Name): + parts.append(value.id) + return ".".join(reversed(parts)) + + def _validate_binding_expressions( binding: DiscoveredSwaggerBinding, operation: SwaggerOperation, @@ -473,15 +788,27 @@ def _validate_expression( ), ), ) - if field_name not in request_body.field_names: + if request_body.schema is None: + return ( + _expression_error( + binding=binding, + code="SWAGGER_BINDING_BODY_SCHEMA_UNSUPPORTED", + message=( + f"{binding.sdk_method}: {subject}.{argument_name} указывает на " + f"`{expression}`, но requestBody schema не разобрана." + ), + ), + ) + try: + resolve_body_path(request_body.schema, field_name) + except SwaggerSchemaPathError as exc: return ( _expression_error( binding=binding, code="SWAGGER_BINDING_BODY_FIELD_NOT_FOUND", message=( f"{binding.sdk_method}: {subject}.{argument_name} указывает на " - f"`{expression}`, но Swagger requestBody не содержит поле " - f"`{field_name}`." + f"`{expression}`, но {exc}" ), ), ) diff --git a/avito/core/swagger_names.py b/avito/core/swagger_names.py new file mode 100644 index 0000000..805cef9 --- /dev/null +++ b/avito/core/swagger_names.py @@ -0,0 +1,25 @@ +"""Shared Swagger field-name normalization helpers.""" + +from __future__ import annotations + +import re + +_FIRST_CAP_RE = re.compile("(.)([A-Z][a-z]+)") +_ALL_CAP_RE = re.compile("([a-z0-9])([A-Z])") + + +def swagger_field_aliases(field_name: str) -> tuple[str, ...]: + """Return Swagger field name and its SDK-style snake_case alias.""" + + aliases = [field_name] + normalized_field_name = field_name.replace("IDs", "Ids") + snake_case = _ALL_CAP_RE.sub( + r"\1_\2", + _FIRST_CAP_RE.sub(r"\1_\2", normalized_field_name), + ).lower() + if snake_case not in aliases: + aliases.append(snake_case) + return tuple(aliases) + + +__all__ = ("swagger_field_aliases",) diff --git a/avito/core/swagger_registry.py b/avito/core/swagger_registry.py index e94d884..fa4768a 100644 --- a/avito/core/swagger_registry.py +++ b/avito/core/swagger_registry.py @@ -5,16 +5,16 @@ import json import re from collections.abc import Iterable, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import cast +from avito.core.swagger_names import swagger_field_aliases + HTTP_METHODS = frozenset({"delete", "get", "head", "options", "patch", "post", "put", "trace"}) DEFAULT_SWAGGER_API_DIR = Path("docs/avito/api") _PATH_PARAMETER_RE = re.compile(r"{([A-Za-z_][A-Za-z0-9_]*)}") -_FIRST_CAP_RE = re.compile("(.)([A-Z][a-z]+)") -_ALL_CAP_RE = re.compile("([a-z0-9])([A-Z])") JsonObject = dict[str, object] @@ -49,6 +49,7 @@ class SwaggerRequestBody: content_types: tuple[str, ...] field_names: tuple[str, ...] schema_extracted: bool + schema: SwaggerSchema | None = None @dataclass(frozen=True, slots=True) @@ -57,6 +58,7 @@ class SwaggerResponse: status_code: str content_types: tuple[str, ...] + schema: SwaggerSchema | None = None @property def is_success(self) -> bool: @@ -69,6 +71,27 @@ def is_error(self) -> bool: ) +@dataclass(frozen=True, slots=True) +class SwaggerSchema: + """Normalized JSON schema subset used by strict SDK contract tests.""" + + kind: str + properties: Mapping[str, SwaggerSchema] = field(default_factory=dict) + required: tuple[str, ...] = () + items: SwaggerSchema | None = None + variants: tuple[SwaggerSchema, ...] = () + nullable: bool = False + enum: tuple[object, ...] = () + + @property + def is_object(self) -> bool: + return self.kind == "object" + + @property + def is_array(self) -> bool: + return self.kind == "array" + + @dataclass(frozen=True, slots=True) class SwaggerOperation: """Одна Swagger/OpenAPI operation с normalized identity.""" @@ -141,7 +164,7 @@ def load_swagger_registry( spec_paths = tuple(sorted(api_dir.glob("*.json"))) if not spec_paths: - raise SwaggerRegistryError(f"В каталоге {api_dir} не найдены Swagger JSON files.") + raise SwaggerRegistryError(f"В каталоге {api_dir} не найдены JSON-файлы Swagger.") errors: list[SwaggerValidationError] = [] specs = tuple(_load_spec(path, errors) for path in spec_paths) @@ -194,7 +217,9 @@ def _load_spec(path: Path, errors: list[SwaggerValidationError]) -> SwaggerSpec: path_item = _require_mapping(raw_path_item, f"{path}: path item {raw_path}") path_parameters = _extract_parameters( spec=spec, - parameters=_optional_sequence(path_item.get("parameters"), f"{path}: {raw_path}.parameters"), + parameters=_optional_sequence( + path_item.get("parameters"), f"{path}: {raw_path}.parameters" + ), source=f"{path}: {raw_path}.parameters", ) for raw_method, raw_operation in sorted(path_item.items()): @@ -234,7 +259,7 @@ def _load_spec(path: Path, errors: list[SwaggerValidationError]) -> SwaggerSpec: spec=spec, raw_request_body=operation.get("requestBody"), ), - responses=_extract_responses(operation.get("responses")), + responses=_extract_responses(operation.get("responses"), spec=spec), ) ) @@ -279,6 +304,7 @@ def _extract_request_body( field_names: set[str] = set() schema_extracted = True schema_count = 0 + selected_schema: SwaggerSchema | None = None for content_type, raw_media_type in content.items(): media_type = _require_mapping( raw_media_type, @@ -289,6 +315,16 @@ def _extract_request_body( schema_extracted = False continue schema_count += 1 + parsed_schema = _extract_schema( + spec=spec, + raw_schema=raw_schema, + source=f"requestBody.content.{content_type}.schema", + seen_refs=frozenset(), + ) + if parsed_schema is None: + schema_extracted = False + elif selected_schema is None or str(content_type) == "application/json": + selected_schema = parsed_schema extracted = _extract_schema_field_names( spec=spec, raw_schema=raw_schema, @@ -306,24 +342,54 @@ def _extract_request_body( content_types=tuple(sorted(str(content_type) for content_type in content)), field_names=tuple(sorted(field_names)), schema_extracted=schema_extracted, + schema=selected_schema, ) -def _extract_responses(raw_responses: object) -> tuple[SwaggerResponse, ...]: +def _extract_responses( + raw_responses: object, + *, + spec: Mapping[str, object], +) -> tuple[SwaggerResponse, ...]: responses = _require_mapping(raw_responses, "responses") extracted: list[SwaggerResponse] = [] for raw_status_code, raw_response in sorted(responses.items()): if not isinstance(raw_status_code, str): raise SwaggerRegistryError("responses должен использовать строковые status codes.") - response = _require_mapping(raw_response, f"responses.{raw_status_code}") + response = _resolve_component_ref( + spec=spec, + raw_value=raw_response, + source=f"responses.{raw_status_code}", + component_name="responses", + ) content = response.get("content") content_types: tuple[str, ...] = () + selected_schema: SwaggerSchema | None = None if isinstance(content, Mapping): content_types = tuple(sorted(str(content_type) for content_type in content)) + for content_type, raw_media_type in content.items(): + media_type = _require_mapping( + raw_media_type, + f"responses.{raw_status_code}.content.{content_type}", + ) + raw_schema = media_type.get("schema") + if raw_schema is None: + continue + parsed_schema = _extract_schema( + spec=spec, + raw_schema=raw_schema, + source=f"responses.{raw_status_code}.content.{content_type}.schema", + seen_refs=frozenset(), + ) + if parsed_schema is not None and ( + selected_schema is None or str(content_type) == "application/json" + ): + selected_schema = parsed_schema extracted.append( SwaggerResponse( status_code=raw_status_code, content_types=content_types, + schema=selected_schema, ) ) return tuple(extracted) @@ -414,7 +480,9 @@ def _validate_unique_operation_keys( ) -def _resolve_ref(spec: Mapping[str, object], raw_value: object, source: str) -> Mapping[str, object]: +def _resolve_ref( + spec: Mapping[str, object], raw_value: object, source: str +) -> Mapping[str, object]: return _resolve_component_ref( spec=spec, raw_value=raw_value, @@ -496,6 +564,241 @@ def _extract_schema_field_names( return None +def _extract_schema( + *, + spec: Mapping[str, object], + raw_schema: object, + source: str, + seen_refs: frozenset[str], +) -> SwaggerSchema | None: + schema = _require_mapping(raw_schema, source) + raw_ref = schema.get("$ref") + if raw_ref is not None: + ref = _required_string(raw_ref, f"{source}.$ref") + if ref in seen_refs: + return SwaggerSchema(kind="object") + prefix = "#/components/schemas/" + if not ref.startswith(prefix): + return None + schema_name = ref.removeprefix(prefix) + components = _require_mapping(spec.get("components"), "components") + schemas = _require_mapping(components.get("schemas"), "components.schemas") + resolved = _require_mapping(schemas.get(schema_name), ref) + return _extract_schema( + spec=spec, + raw_schema=resolved, + source=ref, + seen_refs=seen_refs | frozenset({ref}), + ) + + composed_schema = _extract_composed_schema( + spec=spec, + schema=schema, + source=source, + seen_refs=seen_refs, + ) + if composed_schema is not None: + return composed_schema + + raw_type = schema.get("type") + nullable = schema.get("nullable") is True + raw_enum = schema.get("enum") + enum_values = tuple(raw_enum) if isinstance(raw_enum, list) else () + if isinstance(raw_type, Mapping): + nested_type = raw_type.get("type") + nested_enum = raw_type.get("enum") + if isinstance(nested_enum, list) and not enum_values: + enum_values = tuple(nested_enum) + raw_type = nested_type if isinstance(nested_type, str | list) else None + if isinstance(raw_type, list): + non_null_types = tuple(item for item in raw_type if item != "null") + nullable = nullable or len(non_null_types) != len(raw_type) + raw_type = non_null_types[0] if len(non_null_types) == 1 else None + if raw_type is None and "properties" in schema: + raw_type = "object" + if raw_type is None and "items" in schema: + raw_type = "array" + if raw_type is None and enum_values: + raw_type = _infer_enum_type(enum_values) + if raw_type is None and "additionalProperties" in schema: + raw_type = "object" + if raw_type is None and ("minimum" in schema or "maximum" in schema): + example = schema.get("example") + raw_type = ( + "integer" if isinstance(example, int) and not isinstance(example, bool) else "number" + ) + if isinstance(raw_type, str) and raw_type not in { + "object", + "array", + "string", + "integer", + "number", + "boolean", + "null", + }: + raw_type = _infer_enum_type(enum_values) + if raw_type is None: + return None + if raw_type == "object": + properties: dict[str, SwaggerSchema] = {} + raw_properties = schema.get("properties") + if isinstance(raw_properties, Mapping): + for property_name, raw_property_schema in raw_properties.items(): + property_schema = _extract_schema( + spec=spec, + raw_schema=raw_property_schema, + source=f"{source}.properties.{property_name}", + seen_refs=seen_refs, + ) + if property_schema is None: + return None + properties[str(property_name)] = property_schema + return SwaggerSchema( + kind="object", + properties=properties, + required=_known_required_field_names(schema.get("required"), properties, source), + nullable=nullable, + enum=enum_values, + ) + if raw_type == "array": + raw_items = schema.get("items") + if raw_items is None: + item_kind = _infer_enum_type(enum_values) + if item_kind is None: + return None + return SwaggerSchema( + kind="array", + items=SwaggerSchema(kind=item_kind, enum=enum_values), + nullable=nullable, + enum=enum_values, + ) + items = _extract_schema( + spec=spec, + raw_schema=raw_items, + source=f"{source}.items", + seen_refs=seen_refs, + ) + if items is None: + return None + return SwaggerSchema( + kind="array", + items=items, + nullable=nullable, + enum=enum_values, + ) + if raw_type in {"string", "integer", "number", "boolean", "null"}: + return SwaggerSchema(kind=str(raw_type), nullable=nullable, enum=enum_values) + return None + + +def _extract_composed_schema( + *, + spec: Mapping[str, object], + schema: Mapping[str, object], + source: str, + seen_refs: frozenset[str], +) -> SwaggerSchema | None: + raw_all_of = schema.get("allOf") + if raw_all_of is not None: + if not isinstance(raw_all_of, list): + return None + if len(raw_all_of) == 1: + single_schema = _extract_schema( + spec=spec, + raw_schema=raw_all_of[0], + source=f"{source}.allOf[0]", + seen_refs=seen_refs, + ) + if single_schema is not None: + return SwaggerSchema( + kind=single_schema.kind, + properties=single_schema.properties, + required=single_schema.required, + items=single_schema.items, + variants=single_schema.variants, + nullable=single_schema.nullable or schema.get("nullable") is True, + enum=single_schema.enum, + ) + merged_properties: dict[str, SwaggerSchema] = {} + required: set[str] = set() + nullable = schema.get("nullable") is True + for index, raw_item in enumerate(raw_all_of): + item_schema = _extract_schema( + spec=spec, + raw_schema=raw_item, + source=f"{source}.allOf[{index}]", + seen_refs=seen_refs, + ) + if item_schema is None and _is_description_only_schema(raw_item): + continue + if item_schema is None or not item_schema.is_object: + return None + merged_properties.update(item_schema.properties) + required.update(item_schema.required) + nullable = nullable or item_schema.nullable + required.update(_required_field_names(schema.get("required"), source)) + return SwaggerSchema( + kind="object", + properties=merged_properties, + required=tuple(name for name in sorted(required) if name in merged_properties), + nullable=nullable, + ) + + for keyword in ("oneOf", "anyOf"): + raw_variants = schema.get(keyword) + if raw_variants is None: + continue + if not isinstance(raw_variants, list): + return None + variants: list[SwaggerSchema] = [] + for index, raw_item in enumerate(raw_variants): + item_schema = _extract_schema( + spec=spec, + raw_schema=raw_item, + source=f"{source}.{keyword}[{index}]", + seen_refs=seen_refs, + ) + if item_schema is None: + return None + variants.append(item_schema) + return SwaggerSchema( + kind="union", + variants=tuple(variants), + nullable=schema.get("nullable") is True, + ) + return None + + +def _required_field_names(value: object, source: str) -> tuple[str, ...]: + if value is None: + return () + if not isinstance(value, list): + raise SwaggerRegistryError(f"{source}.required должно быть списком.") + return tuple(sorted(str(item) for item in value)) + + +def _known_required_field_names( + value: object, + properties: Mapping[str, SwaggerSchema], + source: str, +) -> tuple[str, ...]: + return tuple(name for name in _required_field_names(value, source) if name in properties) + + +def _is_description_only_schema(value: object) -> bool: + return isinstance(value, Mapping) and set(value) <= {"description", "title"} + + +def _infer_enum_type(values: tuple[object, ...]) -> str | None: + if all(isinstance(value, str) for value in values): + return "string" + if all(isinstance(value, int) and not isinstance(value, bool) for value in values): + return "integer" + if all(isinstance(value, int | float) and not isinstance(value, bool) for value in values): + return "number" + return None + + def _extract_composed_schema_field_names( *, spec: Mapping[str, object], @@ -524,14 +827,7 @@ def _extract_composed_schema_field_names( def _field_name_aliases(field_name: str) -> set[str]: - aliases = {field_name} - normalized_field_name = field_name.replace("IDs", "Ids") - snake_case = _ALL_CAP_RE.sub( - r"\1_\2", - _FIRST_CAP_RE.sub(r"\1_\2", normalized_field_name), - ).lower() - aliases.add(snake_case) - return aliases + return set(swagger_field_aliases(field_name)) def _optional_sequence(value: object, source: str) -> tuple[object, ...]: @@ -565,6 +861,8 @@ def _optional_string(value: object) -> str | None: "SwaggerRegistry", "SwaggerRegistryError", "SwaggerRequestBody", + "SwaggerResponse", + "SwaggerSchema", "SwaggerSpec", "SwaggerValidationError", "load_swagger_registry", diff --git a/avito/core/swagger_report.py b/avito/core/swagger_report.py index 522dccb..a89fe18 100644 --- a/avito/core/swagger_report.py +++ b/avito/core/swagger_report.py @@ -38,21 +38,13 @@ def to_dict(self) -> dict[str, object]: _build_operation_entry(operation, binding_groups.get(operation.key, ())) for operation in self.registry.operations ] - binding_entries = [ - _build_binding_entry(binding) for binding in self.discovery.bindings - ] - duplicate_operations = sum( - 1 for bindings in binding_groups.values() if len(bindings) > 1 - ) + binding_entries = [_build_binding_entry(binding) for binding in self.discovery.bindings] + duplicate_operations = sum(1 for bindings in binding_groups.values() if len(bindings) > 1) ambiguous_bindings = sum( 1 for binding in self.discovery.bindings if binding.operation_key is None ) - bound_operations = sum( - 1 for entry in operation_entries if entry["status"] == "bound" - ) - unbound_operations = sum( - 1 for entry in operation_entries if entry["status"] == "unbound" - ) + bound_operations = sum(1 for entry in operation_entries if entry["status"] == "bound") + unbound_operations = sum(1 for entry in operation_entries if entry["status"] == "unbound") return { "summary": { diff --git a/avito/core/swagger_schema_paths.py b/avito/core/swagger_schema_paths.py new file mode 100644 index 0000000..f004af1 --- /dev/null +++ b/avito/core/swagger_schema_paths.py @@ -0,0 +1,97 @@ +"""Validation helpers for Swagger binding body path expressions.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from avito.core.swagger_names import swagger_field_aliases +from avito.core.swagger_registry import SwaggerSchema + + +class SwaggerSchemaPathError(ValueError): + """Binding expression path cannot be resolved against a Swagger schema.""" + + +@dataclass(frozen=True, slots=True) +class SwaggerBodyPathSegment: + """One segment of a restricted body path expression.""" + + name: str + raw_name: str + array: bool = False + + +@dataclass(frozen=True, slots=True) +class SwaggerBodyPath: + """Parsed restricted body path expression.""" + + expression: str + segments: tuple[SwaggerBodyPathSegment, ...] + leaf_schema: SwaggerSchema + + @property + def leaf_name(self) -> str: + return self.segments[-1].name + + +def parse_body_path(path: str) -> tuple[SwaggerBodyPathSegment, ...]: + """Parse `field`, `items[]` and `items[].field` body path syntax.""" + + if not path: + raise SwaggerSchemaPathError("body path не может быть пустым.") + segments: list[SwaggerBodyPathSegment] = [] + for raw_segment in path.split("."): + if not raw_segment: + raise SwaggerSchemaPathError(f"Некорректный body path `{path}`.") + array = raw_segment.endswith("[]") + name = raw_segment.removesuffix("[]") + if not name: + raise SwaggerSchemaPathError(f"Некорректный body path segment `{raw_segment}`.") + segments.append(SwaggerBodyPathSegment(name=name, raw_name=raw_segment, array=array)) + return tuple(segments) + + +def resolve_body_path(schema: SwaggerSchema, path: str) -> SwaggerBodyPath: + """Resolve a parsed body path against normalized Swagger schema metadata.""" + + current = schema + segments = parse_body_path(path) + for segment in segments: + raw_property = _maybe_resolve_property(current, segment.raw_name) + if raw_property is not None: + current = raw_property + continue + current = _resolve_property(current, segment.name, path) + if segment.array: + if not current.is_array or current.items is None: + raise SwaggerSchemaPathError( + f"`{segment.name}[]` указывает на не-array schema в `{path}`." + ) + current = current.items + return SwaggerBodyPath(expression=path, segments=segments, leaf_schema=current) + + +def _maybe_resolve_property(schema: SwaggerSchema, name: str) -> SwaggerSchema | None: + if not schema.is_object: + return None + return schema.properties.get(name) + + +def _resolve_property(schema: SwaggerSchema, name: str, path: str) -> SwaggerSchema: + if not schema.is_object: + raise SwaggerSchemaPathError(f"`{name}` указывает на не-object schema в `{path}`.") + if name in schema.properties: + return schema.properties[name] + for property_name, property_schema in schema.properties.items(): + if name in swagger_field_aliases(property_name): + return property_schema + raise SwaggerSchemaPathError(f"Swagger schema не содержит поле `{name}` в `{path}`.") + + +__all__ = ( + "SwaggerBodyPath", + "SwaggerBodyPathSegment", + "SwaggerSchemaPathError", + "parse_body_path", + "resolve_body_path", +) diff --git a/avito/core/transport.py b/avito/core/transport.py index 0cd84e2..d6be29b 100644 --- a/avito/core/transport.py +++ b/avito/core/transport.py @@ -13,7 +13,7 @@ from email.utils import parsedate_to_datetime from io import BytesIO from typing import TYPE_CHECKING, cast -from urllib.parse import quote +from urllib.parse import quote, urlsplit import httpx @@ -21,10 +21,8 @@ AuthenticationError, AuthorizationError, ConflictError, - NotFoundError, RateLimitError, ResponseMappingError, - ServerError, TransportError, UnsupportedOperationError, UpstreamApiError, @@ -153,12 +151,15 @@ def request( "transport rate limit delay", extra={ "operation": context.operation_name, + "endpoint": self._safe_endpoint(normalized_path), + "method": method, "attempt": attempt, "delay_ms": int(limiter_delay * 1000), "reason": "client_rate_limit", }, ) try: + started_at = time.perf_counter() response = self._client.request( method=method, url=normalized_path, @@ -170,10 +171,28 @@ def request( content=content, timeout=timeout, ) + self._log_http_exchange( + operation=context.operation_name, + endpoint=normalized_path, + method=method, + attempt=attempt, + status=response.status_code, + latency_ms=self._elapsed_ms(started_at), + request_id=self._extract_request_id(response.headers), + ) self._rate_limiter.observe_response( headers=response.headers, ) except (httpx.TimeoutException, httpx.NetworkError) as exc: + self._log_http_exchange( + operation=context.operation_name, + endpoint=normalized_path, + method=method, + attempt=attempt, + status=None, + latency_ms=self._elapsed_ms(started_at), + request_id=None, + ) decision = self._decide_transport_retry( method=method, attempt=attempt, @@ -184,6 +203,8 @@ def request( if decision.should_retry: self._log_retry( operation=context.operation_name, + endpoint=normalized_path, + method=method, attempt=attempt, status=None, decision=decision, @@ -193,6 +214,9 @@ def request( raise TransportError( str(exc), operation=context.operation_name, + attempt=attempt, + method=method, + endpoint=self._safe_endpoint(normalized_path), metadata={"timeout": isinstance(exc, httpx.TimeoutException)}, ) from exc @@ -202,7 +226,11 @@ def request( and self._auth_provider is not None ): if unauthorized_refresh_used: - raise self._map_http_error(response, operation=context.operation_name) + raise self._map_http_error( + response, + operation=context.operation_name, + attempt=attempt, + ) unauthorized_refresh_used = True self._auth_provider.invalidate_token() refreshed_headers = dict(request_headers) @@ -223,13 +251,19 @@ def request( if decision.should_retry: self._log_retry( operation=context.operation_name, + endpoint=normalized_path, + method=method, attempt=attempt, status=response.status_code, decision=decision, ) self._sleep(decision.delay_seconds) continue - raise self._map_http_error(response, operation=context.operation_name) + raise self._map_http_error( + response, + operation=context.operation_name, + attempt=attempt, + ) if 500 <= response.status_code < 600: decision = self._decide_http_retry( @@ -242,16 +276,26 @@ def request( if decision.should_retry: self._log_retry( operation=context.operation_name, + endpoint=normalized_path, + method=method, attempt=attempt, status=response.status_code, decision=decision, ) self._sleep(decision.delay_seconds) continue - raise self._map_http_error(response, operation=context.operation_name) + raise self._map_http_error( + response, + operation=context.operation_name, + attempt=attempt, + ) if response.is_error: - raise self._map_http_error(response, operation=context.operation_name) + raise self._map_http_error( + response, + operation=context.operation_name, + attempt=attempt, + ) return response @@ -293,35 +337,6 @@ 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, @@ -355,7 +370,7 @@ def _normalize_path(self, path: str) -> str: return stripped has_trailing_slash = stripped.endswith("/") segments = [ - quote(segment, safe=":@") for segment in stripped.strip("/").split("/") if segment + quote(segment, safe=":@%") for segment in stripped.strip("/").split("/") if segment ] normalized = "/" + "/".join(segments) if has_trailing_slash and normalized != "/": @@ -492,27 +507,43 @@ def _is_retryable_request( context: RequestContext, idempotency_key: str | None, ) -> bool: + if context.retry_disabled: + return False normalized_method = method.upper() if normalized_method in {"POST", "PATCH"} and idempotency_key is None: return False + if ( + normalized_method == "DELETE" + and idempotency_key is None + and not context.allow_retry + ): + return False return 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 + self, + response: httpx.Response, + *, + operation: str | None = None, + attempt: int | None = None, ) -> Exception: payload = self._safe_payload(response) message = self._extract_message(payload) or f"HTTP {response.status_code}" error_code = self._extract_error_code(payload) details = self._extract_error_details(payload) - retry_after = self._get_retry_after_seconds(response.headers) if response.status_code == 429 else None + retry_after = ( + self._get_retry_after_seconds(response.headers) if response.status_code == 429 else None + ) request_id = self._extract_request_id(response.headers) headers = dict(response.headers) + method = response.request.method + endpoint = response.request.url.path metadata = { - "method": response.request.method, - "path": response.request.url.path, + "method": method, + "path": endpoint, } if response.status_code == 401: @@ -521,6 +552,9 @@ def _map_http_error( status_code=401, error_code=error_code, operation=operation, + attempt=attempt, + method=method, + endpoint=endpoint, details=details, retry_after=retry_after, request_id=request_id, @@ -534,19 +568,9 @@ def _map_http_error( status_code=403, error_code=error_code, operation=operation, - details=details, - retry_after=retry_after, - request_id=request_id, - metadata=metadata, - payload=payload, - headers=headers, - ) - if response.status_code == 404: - return NotFoundError( - message, - status_code=404, - error_code=error_code, - operation=operation, + attempt=attempt, + method=method, + endpoint=endpoint, details=details, retry_after=retry_after, request_id=request_id, @@ -560,6 +584,9 @@ def _map_http_error( status_code=response.status_code, error_code=error_code, operation=operation, + attempt=attempt, + method=method, + endpoint=endpoint, details=details, retry_after=retry_after, request_id=request_id, @@ -573,6 +600,9 @@ def _map_http_error( status_code=409, error_code=error_code, operation=operation, + attempt=attempt, + method=method, + endpoint=endpoint, details=details, retry_after=retry_after, request_id=request_id, @@ -586,6 +616,9 @@ def _map_http_error( status_code=429, error_code=error_code, operation=operation, + attempt=attempt, + method=method, + endpoint=endpoint, details=details, retry_after=retry_after, request_id=request_id, @@ -599,19 +632,9 @@ def _map_http_error( status_code=response.status_code, error_code=error_code, operation=operation, - details=details, - retry_after=retry_after, - request_id=request_id, - metadata=metadata, - payload=payload, - headers=headers, - ) - if response.status_code >= 500: - return ServerError( - message, - status_code=response.status_code, - error_code=error_code, - operation=operation, + attempt=attempt, + method=method, + endpoint=endpoint, details=details, retry_after=retry_after, request_id=request_id, @@ -624,6 +647,9 @@ def _map_http_error( status_code=response.status_code, error_code=error_code, operation=operation, + attempt=attempt, + method=method, + endpoint=endpoint, details=details, retry_after=retry_after, request_id=request_id, @@ -692,6 +718,8 @@ def _log_retry( self, *, operation: str, + endpoint: str, + method: str, attempt: int, status: int | None, decision: RetryDecision, @@ -700,6 +728,8 @@ def _log_retry( "transport retry", extra={ "operation": operation, + "endpoint": self._safe_endpoint(endpoint), + "method": method, "attempt": attempt, "status": status, "delay_ms": int(decision.delay_seconds * 1000), @@ -707,6 +737,39 @@ def _log_retry( }, ) + def _log_http_exchange( + self, + *, + operation: str, + endpoint: str, + method: str, + attempt: int, + status: int | None, + latency_ms: int, + request_id: str | None, + ) -> None: + _LOGGER.debug( + "transport http exchange", + extra={ + "operation": operation, + "endpoint": self._safe_endpoint(endpoint), + "method": method, + "attempt": attempt, + "status": status, + "latency_ms": latency_ms, + "request_id": request_id, + }, + ) + + def _elapsed_ms(self, started_at: float) -> int: + return max(int((time.perf_counter() - started_at) * 1000), 0) + + def _safe_endpoint(self, endpoint: str) -> str: + parsed = urlsplit(endpoint) + if parsed.scheme or parsed.netloc: + return parsed.path or "/" + return endpoint + def _extract_filename(self, content_disposition: str | None) -> str | None: if content_disposition is None: return None @@ -718,4 +781,5 @@ def _extract_filename(self, content_disposition: str | None) -> str | None: return decoded_value return filename + __all__ = ("Transport", "build_httpx_timeout") diff --git a/avito/core/types.py b/avito/core/types.py index 5c04e47..10ea4cf 100644 --- a/avito/core/types.py +++ b/avito/core/types.py @@ -10,6 +10,7 @@ from avito._env import parse_env_float, resolve_env_aliases HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] +RetryOverride = Literal["default", "enabled", "disabled"] @dataclass(slots=True, frozen=True) @@ -46,6 +47,7 @@ class RequestContext: operation_name: str allow_retry: bool = False + retry_disabled: bool = False requires_auth: bool = True timeout: ApiTimeouts | None = None headers: Mapping[str, str] = field(default_factory=dict) @@ -108,5 +110,6 @@ def has_next(self) -> bool: "HttpMethod", "JsonPage", "RequestContext", + "RetryOverride", "TransportDebugInfo", ) diff --git a/avito/core/validation.py b/avito/core/validation.py index a1be54d..5d30064 100644 --- a/avito/core/validation.py +++ b/avito/core/validation.py @@ -3,9 +3,12 @@ from __future__ import annotations from collections.abc import Sequence +from datetime import date, datetime from avito.core.exceptions import ValidationError +DateInput = date | datetime | str + def validate_non_empty(name: str, items: Sequence[object]) -> None: """Проверяет, что последовательность содержит хотя бы один элемент.""" @@ -32,7 +35,46 @@ def validate_string_items(name: str, values: Sequence[str]) -> None: validate_non_empty_string(f"{name}[{index}]", value) +def serialize_iso_date(name: str, value: DateInput) -> str: + """Проверяет ISO-дату и сериализует значение в YYYY-MM-DD.""" + + if isinstance(value, datetime): + return value.date().isoformat() + if isinstance(value, date): + return value.isoformat() + normalized = value.strip() + if not normalized: + raise ValidationError(f"`{name}` не может быть пустой строкой.") + try: + return datetime.fromisoformat(normalized.replace("Z", "+00:00")).date().isoformat() + except ValueError: + try: + return date.fromisoformat(normalized).isoformat() + except ValueError as exc: + raise ValidationError(f"`{name}` должен быть датой в ISO-формате.") from exc + + +def serialize_iso_datetime(name: str, value: DateInput) -> str: + """Проверяет ISO/RFC3339 datetime и сохраняет строковое представление.""" + + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, date): + return value.isoformat() + normalized = value.strip() + if not normalized: + raise ValidationError(f"`{name}` не может быть пустой строкой.") + try: + datetime.fromisoformat(normalized.replace("Z", "+00:00")) + except ValueError as exc: + raise ValidationError(f"`{name}` должен быть датой или временем в ISO-формате.") from exc + return normalized + + __all__ = ( + "DateInput", + "serialize_iso_date", + "serialize_iso_datetime", "validate_non_empty", "validate_non_empty_string", "validate_positive_int", diff --git a/avito/cpa/__init__.py b/avito/cpa/__init__.py index 286030d..e517d26 100644 --- a/avito/cpa/__init__.py +++ b/avito/cpa/__init__.py @@ -1,7 +1,6 @@ """Пакет cpa.""" from avito.cpa.domain import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead -from avito.cpa.enums import CpaCallStatusId from avito.cpa.models import ( CallTrackingCallInfo, CallTrackingCallResponse, @@ -17,6 +16,7 @@ CpaCallInfo, CpaCallsByTimeRequest, CpaCallsResult, + CpaCallStatusId, CpaChatInfo, CpaChatsByTimeRequest, CpaChatsResult, diff --git a/avito/cpa/client.py b/avito/cpa/client.py deleted file mode 100644 index e5dc6fe..0000000 --- a/avito/cpa/client.py +++ /dev/null @@ -1,260 +0,0 @@ -"""Внутренние section clients для пакета cpa.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from avito.core import RequestContext, Transport -from avito.core.mapping import request_public_model -from avito.cpa.mappers import ( - map_balance, - map_call_item, - map_call_tracking_call_item, - map_call_tracking_calls, - map_calls, - map_chat_item, - map_chats, - map_cpa_action, - map_phones, -) -from avito.cpa.models import ( - CallTrackingCallResponse, - CallTrackingCallsRequest, - CallTrackingCallsResult, - CallTrackingGetCallByIdRequest, - CallTrackingRecord, - CpaActionResult, - CpaAudioRecord, - CpaBalanceInfo, - CpaCallByIdRequest, - CpaCallComplaintRequest, - CpaCallInfo, - CpaCallsByTimeRequest, - CpaCallsResult, - CpaChatInfo, - CpaChatsByTimeRequest, - CpaChatsResult, - CpaLeadComplaintRequest, - CpaPhonesFromChatsRequest, - CpaPhonesResult, -) - -_CPA_HEADERS = {"X-Source": "avito-py"} - - -def _cpa_context(operation_name: str, *, allow_retry: bool = False) -> RequestContext: - return RequestContext( - operation_name, - allow_retry=allow_retry, - headers=_CPA_HEADERS, - ) - - -@dataclass(slots=True, frozen=True) -class CpaChatsClient: - """Выполняет HTTP-операции CPA-чатов.""" - - transport: Transport - - def get_by_action_id(self, *, action_id: int | str) -> CpaChatInfo: - return request_public_model( - self.transport, - "GET", - f"/cpa/v1/chatByActionId/{action_id}", - context=_cpa_context("cpa.chats.get_by_action_id"), - mapper=map_chat_item, - ) - - 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=_cpa_context("cpa.chats.list_by_time_classic", allow_retry=True), - mapper=map_chats, - json_body=CpaChatsByTimeRequest( - created_at_from=created_at_from, - limit=limit, - ).to_payload(), - ) - - 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=_cpa_context("cpa.chats.list_by_time", allow_retry=True), - mapper=map_chats, - json_body=CpaChatsByTimeRequest( - created_at_from=created_at_from, - limit=limit, - ).to_payload(), - ) - - def get_phones_info(self, *, action_ids: list[str]) -> CpaPhonesResult: - return request_public_model( - self.transport, - "POST", - "/cpa/v1/phonesInfoFromChats", - context=_cpa_context("cpa.chats.get_phones_info", allow_retry=True), - mapper=map_phones, - json_body=CpaPhonesFromChatsRequest(action_ids=action_ids).to_payload(), - ) - - -@dataclass(slots=True, frozen=True) -class CpaCallsClient: - """Выполняет HTTP-операции CPA-звонков.""" - - transport: Transport - - 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=_cpa_context("cpa.calls.list_by_time", allow_retry=True), - mapper=map_calls, - json_body=CpaCallsByTimeRequest( - date_time_from=date_time_from, - date_time_to=date_time_to, - ).to_payload(), - ) - - 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=_cpa_context( - "cpa.calls.create_complaint", - allow_retry=idempotency_key is not None, - ), - mapper=map_cpa_action, - json_body=CpaCallComplaintRequest(call_id=call_id, reason=reason).to_payload(), - idempotency_key=idempotency_key, - ) - - -@dataclass(slots=True, frozen=True) -class CpaLeadsClient: - """Выполняет HTTP-операции CPA-лидов и связанных сущностей.""" - - transport: Transport - - 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=_cpa_context( - "cpa.leads.create_complaint_by_action_id", - allow_retry=idempotency_key is not None, - ), - mapper=map_cpa_action, - json_body=CpaLeadComplaintRequest(action_id=action_id, reason=reason).to_payload(), - idempotency_key=idempotency_key, - ) - - def get_balance_info(self) -> CpaBalanceInfo: - return request_public_model( - self.transport, - "POST", - "/cpa/v3/balanceInfo", - context=_cpa_context("cpa.leads.get_balance_info", allow_retry=True), - mapper=map_balance, - json_body={}, - ) - - -@dataclass(slots=True, frozen=True) -class CpaArchiveClient: - """Выполняет архивные HTTP-операции CPA.""" - - transport: Transport - - def get_record(self, *, call_id: int | str) -> CpaAudioRecord: - binary = self.transport.download_binary( - f"/cpa/v1/call/{call_id}", - context=_cpa_context("cpa.archive.get_record"), - ) - return CpaAudioRecord(binary) - - def get_balance_info(self) -> CpaBalanceInfo: - return request_public_model( - self.transport, - "POST", - "/cpa/v2/balanceInfo", - context=_cpa_context("cpa.archive.get_balance_info", allow_retry=True), - mapper=map_balance, - json_body={}, - ) - - def get_call_by_id(self, *, call_id: int) -> CpaCallInfo: - return request_public_model( - self.transport, - "POST", - "/cpa/v2/callById", - context=_cpa_context("cpa.archive.get_call_by_id", allow_retry=True), - mapper=map_call_item, - json_body=CpaCallByIdRequest(call_id=call_id).to_payload(), - ) - - -@dataclass(slots=True, frozen=True) -class CallTrackingClient: - """Выполняет HTTP-операции CallTracking.""" - - transport: Transport - - 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=CallTrackingGetCallByIdRequest(call_id=call_id).to_payload(), - ) - - 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=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: - binary = self.transport.download_binary( - "/calltracking/v1/getRecordByCallId/", - context=RequestContext("cpa.calltracking.get_record_by_call_id"), - params={"callId": call_id}, - ) - return CallTrackingRecord(binary) diff --git a/avito/cpa/domain.py b/avito/cpa/domain.py index e7885a7..0b5dc95 100644 --- a/avito/cpa/domain.py +++ b/avito/cpa/domain.py @@ -2,33 +2,52 @@ from __future__ import annotations -from collections.abc import Sequence from dataclasses import dataclass -from avito.core import ValidationError +from avito.core import ApiTimeouts, RetryOverride, ValidationError from avito.core.deprecation import deprecated_method, warn_deprecated_once from avito.core.domain import DomainObject from avito.core.swagger import swagger_operation -from avito.cpa.client import ( - CallTrackingClient, - CpaArchiveClient, - CpaCallsClient, - CpaChatsClient, - CpaLeadsClient, -) +from avito.core.validation import DateInput, serialize_iso_datetime from avito.cpa.models import ( CallTrackingCallResponse, + CallTrackingCallsRequest, CallTrackingCallsResult, + CallTrackingGetCallByIdRequest, CallTrackingRecord, CpaActionResult, CpaAudioRecord, CpaBalanceInfo, + CpaBalanceInfoRequest, + CpaCallByIdRequest, + CpaCallComplaintRequest, CpaCallInfo, + CpaCallsByTimeRequest, CpaCallsResult, CpaChatInfo, + CpaChatsByTimeRequest, CpaChatsResult, + CpaLeadComplaintRequest, + CpaPhonesFromChatsRequest, CpaPhonesResult, ) +from avito.cpa.operations import ( + CPA_HEADERS, + CREATE_CPA_CALL_COMPLAINT, + CREATE_CPA_LEAD_COMPLAINT, + GET_CALLTRACKING_CALL_BY_ID, + GET_CALLTRACKING_CALLS, + GET_CALLTRACKING_RECORD, + GET_CPA_ARCHIVE_BALANCE, + GET_CPA_ARCHIVE_CALL_BY_ID, + GET_CPA_ARCHIVE_RECORD, + GET_CPA_BALANCE, + GET_CPA_CHAT_BY_ACTION_ID, + GET_CPA_PHONES_INFO, + LIST_CPA_CALLS, + LIST_CPA_CHATS, + LIST_CPA_CHATS_CLASSIC, +) @dataclass(slots=True, frozen=True) @@ -50,19 +69,39 @@ class CpaLead(DomainObject): def create_complaint_by_action_id( self, *, - action_id: str, + action_id: int, reason: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> CpaActionResult: - """Выполняет публичную операцию `CpaLead.create_complaint_by_action_id` и возвращает типизированную SDK-модель. + """Создает жалобу по идентификатору CPA-действия. + + Аргументы: + action_id: идентифицирует CPA-действие. + reason: передает причину жалобы или обращения. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaActionResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return CpaLeadsClient(self.transport).create_complaint_by_action_id( - action_id=action_id, - reason=reason, + return self._execute( + CREATE_CPA_LEAD_COMPLAINT, + request=CpaLeadComplaintRequest(action_id=action_id, reason=reason), + headers=CPA_HEADERS, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -71,15 +110,32 @@ def create_complaint_by_action_id( spec="CPAАвито.json", operation_id="balanceInfoV3", ) - def get_balance_info(self) -> CpaBalanceInfo: - """Выполняет публичную операцию `CpaLead.get_balance_info` и возвращает типизированную SDK-модель. + def get_balance_info( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> CpaBalanceInfo: + """Возвращает balance info для CPA-лидов. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Возвращает: + `CpaBalanceInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return CpaLeadsClient(self.transport).get_balance_info() + return self._execute( + GET_CPA_BALANCE, + request=CpaBalanceInfoRequest(), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) @dataclass(slots=True, frozen=True) @@ -99,16 +155,36 @@ class CpaChat(DomainObject): spec="CPAАвито.json", operation_id="chatByActionId", ) - def get(self, *, action_id: int | str | None = None) -> CpaChatInfo: - """Выполняет публичную операцию `CpaChat.get` и возвращает типизированную SDK-модель. + def get( + self, + *, + action_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaChatInfo: + """Возвращает CPA-чатов. + + Аргументы: + action_id: идентифицирует CPA-действие. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `CpaChatInfo` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return CpaChatsClient(self.transport).get_by_action_id( - action_id=action_id or self._require_action_id() + return self._execute( + GET_CPA_CHAT_BY_ACTION_ID, + path_params={"actionId": action_id or self._require_action_id()}, + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -116,45 +192,101 @@ def get(self, *, action_id: int | str | None = None) -> CpaChatInfo: "/cpa/v2/chatsByTime", spec="CPAАвито.json", operation_id="chatsByTime", - method_args={"created_at_from": "body.date_time_from"}, + method_args={ + "created_at_from": "body.dateTimeFrom", + "limit": "body.limit", + "offset": "body.offset", + }, ) def list( self, *, - created_at_from: str, - limit: int | None = None, + created_at_from: DateInput, + limit: int, + offset: int, version: int = 2, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> CpaChatsResult: - """Выполняет публичную операцию `CpaChat.list` и возвращает типизированную SDK-модель. + """Возвращает список CPA-чатов. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Аргументы: + created_at_from: задает нижнюю границу времени создания. + limit: ограничивает размер возвращаемой выборки. + offset: задает смещение первой записи в выборке. + version: задает версию upstream-контракта, если операция ее поддерживает. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Возвращает: + `CpaChatsResult` с типизированными данными ответа API. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - client = CpaChatsClient(self.transport) if version == 1: - return self.list_classic(created_at_from=created_at_from, limit=limit) - return client.list_by_time(created_at_from=created_at_from, limit=limit) + return self.list_classic( + created_at_from=created_at_from, + limit=limit, + offset=offset, + timeout=timeout, + retry=retry, + ) + return self._execute( + LIST_CPA_CHATS, + request=CpaChatsByTimeRequest( + created_at_from=serialize_iso_datetime("created_at_from", created_at_from), + limit=limit, + offset=offset, + ), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) @swagger_operation( "POST", "/cpa/v1/chatsByTime", spec="CPAАвито.json", operation_id="chatsByTime", - method_args={"created_at_from": "body.date_time_from"}, + method_args={ + "created_at_from": "body.dateTimeFrom", + "limit": "body.limit", + "offset": "body.offset", + }, ) def list_classic( self, *, - created_at_from: str, - limit: int | None = None, + created_at_from: DateInput, + limit: int, + offset: int, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> CpaChatsResult: """Выполняет legacy-операцию списка CPA-чатов v1 и возвращает типизированную SDK-модель. - Метод оставлен для явного покрытия отдельной Swagger operation. + Аргументы: + created_at_from: фильтрует CPA-чаты по нижней границе даты создания. + limit: задает максимальное число записей в ответе. + offset: задает смещение выборки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaChatsResult` со списком CPA-чатов legacy API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + Метод оставлен для явного покрытия отдельной Swagger operation. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ warn_deprecated_once( @@ -163,9 +295,16 @@ def list_classic( removal_version="1.3.0", deprecated_since="1.1.0", ) - return CpaChatsClient(self.transport).list_by_time_classic( - created_at_from=created_at_from, - limit=limit, + return self._execute( + LIST_CPA_CHATS_CLASSIC, + request=CpaChatsByTimeRequest( + created_at_from=serialize_iso_datetime("created_at_from", created_at_from), + limit=limit, + offset=offset, + ), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -173,21 +312,51 @@ def list_classic( "/cpa/v1/phonesInfoFromChats", spec="CPAАвито.json", operation_id="phonesInfoFromChats", - method_args={"action_ids": "body.date_time_from"}, + method_args={ + "date_time_from": "body.dateTimeFrom", + "limit": "body.limit", + "offset": "body.offset", + }, ) def get_phones_info_from_chats( self, *, - action_ids: Sequence[str], + date_time_from: DateInput, + limit: int, + offset: int, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> CpaPhonesResult: - """Выполняет публичную операцию `CpaChat.get_phones_info_from_chats` и возвращает типизированную SDK-модель. + """Возвращает phones info from chats для CPA-чатов. + + Аргументы: + date_time_from: задает нижнюю границу времени поиска. + limit: ограничивает размер возвращаемой выборки. + offset: задает смещение первой записи в выборке. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `CpaPhonesResult` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return CpaChatsClient(self.transport).get_phones_info(action_ids=list(action_ids)) + return self._execute( + GET_CPA_PHONES_INFO, + request=CpaPhonesFromChatsRequest( + date_time_from=serialize_iso_datetime("date_time_from", date_time_from), + limit=limit, + offset=offset, + ), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) def _require_action_id(self) -> str: if self.action_id is None: @@ -209,19 +378,49 @@ class CpaCall(DomainObject): "/cpa/v2/callsByTime", spec="CPAАвито.json", operation_id="getCallsByTimeV2", - method_args={"date_time_from": "body.date_time_from", "date_time_to": "body.date_time_from"}, + method_args={ + "date_time_from": "body.dateTimeFrom", + "limit": "body.limit", + }, ) - def list(self, *, date_time_from: str, date_time_to: str) -> CpaCallsResult: - """Выполняет публичную операцию `CpaCall.list` и возвращает типизированную SDK-модель. - - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + def list( + self, + *, + date_time_from: DateInput, + limit: int, + offset: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaCallsResult: + """Возвращает список CPA-звонков. + + Аргументы: + date_time_from: задает начало временного интервала. + limit: ограничивает размер возвращаемой выборки. + offset: задает смещение первой записи в выборке. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaCallsResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return CpaCallsClient(self.transport).list_by_time( - date_time_from=date_time_from, - date_time_to=date_time_to, + return self._execute( + LIST_CPA_CALLS, + request=CpaCallsByTimeRequest( + date_time_from=serialize_iso_datetime("date_time_from", date_time_from), + limit=limit, + offset=offset, + ), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -231,15 +430,43 @@ def list(self, *, date_time_from: str, date_time_to: str) -> CpaCallsResult: operation_id="postCreateComplaint", method_args={"call_id": "body.call_id", "reason": "body.message"}, ) - def create_complaint(self, *, call_id: int, reason: str) -> CpaActionResult: - """Выполняет публичную операцию `CpaCall.create_complaint` и возвращает типизированную SDK-модель. + def create_complaint( + self, + *, + call_id: int, + reason: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaActionResult: + """Создает жалобу по CPA-звонку. + + Аргументы: + call_id: идентифицирует звонок. + reason: передает причину жалобы или обращения. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `CpaActionResult` со статусом выполнения операции. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return CpaCallsClient(self.transport).create_complaint(call_id=call_id, reason=reason) + return self._execute( + CREATE_CPA_CALL_COMPLAINT, + request=CpaCallComplaintRequest(call_id=call_id, reason=reason), + headers=CPA_HEADERS, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) @dataclass(slots=True, frozen=True) @@ -267,16 +494,40 @@ class CpaArchive(DomainObject): removal_version="1.3.0", deprecated_since="1.1.0", ) - def get_call(self, *, call_id: int | str | None = None) -> CpaAudioRecord: + def get_call( + self, + *, + call_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaAudioRecord: """Получает архивную запись звонка. - Deprecated: используйте `call_tracking_call().download`; удаление в версии 1.3.0. + Deprecated: используйте `call_tracking_call().download`; удаление в версии 1.3.0. + + Аргументы: + call_id: идентифицирует архивную запись звонка. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaAudioRecord` с бинарной записью звонка. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return CpaArchiveClient(self.transport).get_record( - call_id=call_id or self._require_call_id() + return CpaAudioRecord( + self._execute( + GET_CPA_ARCHIVE_RECORD, + path_params={"call_id": call_id or self._require_call_id()}, + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) ) @swagger_operation( @@ -293,15 +544,34 @@ def get_call(self, *, call_id: int | str | None = None) -> CpaAudioRecord: removal_version="1.3.0", deprecated_since="1.1.0", ) - def get_balance_info(self) -> CpaBalanceInfo: + def get_balance_info( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> CpaBalanceInfo: """Получает архивный баланс CPA. - Deprecated: используйте `cpa_lead().get_balance_info`; удаление в версии 1.3.0. + Deprecated: используйте `cpa_lead().get_balance_info`; удаление в версии 1.3.0. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaBalanceInfo` с архивной информацией о балансе CPA. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return CpaArchiveClient(self.transport).get_balance_info() + return self._execute( + GET_CPA_ARCHIVE_BALANCE, + request=CpaBalanceInfoRequest(), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) @swagger_operation( "POST", @@ -318,15 +588,39 @@ def get_balance_info(self) -> CpaBalanceInfo: removal_version="1.3.0", deprecated_since="1.1.0", ) - def get_call_by_id(self, *, call_id: int) -> CpaCallInfo: + def get_call_by_id( + self, + *, + call_id: int, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaCallInfo: """Получает архивные данные звонка. - Deprecated: используйте `call_tracking_call().get`; удаление в версии 1.3.0. + Deprecated: используйте `call_tracking_call().get`; удаление в версии 1.3.0. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + call_id: идентифицирует звонок. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaCallInfo` с архивными данными звонка. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return CpaArchiveClient(self.transport).get_call_by_id(call_id=call_id) + return self._execute( + GET_CPA_ARCHIVE_CALL_BY_ID, + request=CpaCallByIdRequest(call_id=call_id), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) def _require_call_id(self) -> str: if self.call_id is None: @@ -351,18 +645,39 @@ class CallTrackingCall(DomainObject): spec="CallTracking[КТ].json", operation_id="get_call_by_id", ) - def get(self, *, call_id: int | None = None) -> CallTrackingCallResponse: - """Выполняет публичную операцию `CallTrackingCall.get` и возвращает типизированную SDK-модель. + def get( + self, + *, + call_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CallTrackingCallResponse: + """Возвращает call tracking звонков. + + Аргументы: + call_id: идентифицирует звонок. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CallTrackingCallResponse` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ resolved_call_id = call_id or (int(self.call_id) if self.call_id is not None else None) if resolved_call_id is None: raise ValidationError("Для операции требуется `call_id`.") - return CallTrackingClient(self.transport).get_call_by_id(call_id=resolved_call_id) + return self._execute( + GET_CALLTRACKING_CALL_BY_ID, + request=CallTrackingGetCallByIdRequest(call_id=resolved_call_id), + timeout=timeout, + retry=retry, + ) @swagger_operation( "POST", @@ -374,23 +689,44 @@ def get(self, *, call_id: int | None = None) -> CallTrackingCallResponse: def list( self, *, - date_time_from: str, - date_time_to: str, + date_time_from: DateInput, + date_time_to: DateInput, limit: int | None = None, offset: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> CallTrackingCallsResult: - """Выполняет публичную операцию `CallTrackingCall.list` и возвращает типизированную SDK-модель. + """Возвращает список call tracking звонков. + + Аргументы: + date_time_from: задает начало временного интервала. + date_time_to: задает конец временного интервала. + limit: ограничивает размер возвращаемой выборки. + offset: задает смещение первой записи в выборке. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `CallTrackingCallsResult` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return CallTrackingClient(self.transport).get_calls( - date_time_from=date_time_from, - date_time_to=date_time_to, - limit=limit, - offset=offset, + return self._execute( + GET_CALLTRACKING_CALLS, + request=CallTrackingCallsRequest( + date_time_from=serialize_iso_datetime("date_time_from", date_time_from), + date_time_to=serialize_iso_datetime("date_time_to", date_time_to), + limit=limit, + offset=offset, + ), + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -400,16 +736,37 @@ def list( operation_id="get_record_by_call_id", method_args={"call_id": "query.callId"}, ) - def download(self, *, call_id: int | str | None = None) -> CallTrackingRecord: - """Выполняет публичную операцию `CallTrackingCall.download` и возвращает типизированную SDK-модель. + def download( + self, + *, + call_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CallTrackingRecord: + """Скачивает запись call tracking звонка. + + Аргументы: + call_id: идентифицирует звонок. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CallTrackingRecord` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return CallTrackingClient(self.transport).get_record_by_call_id( - call_id=call_id or self._require_call_id() + return CallTrackingRecord( + self._execute( + GET_CALLTRACKING_RECORD, + query={"callId": call_id or self._require_call_id()}, + timeout=timeout, + retry=retry, + ) ) def _require_call_id(self) -> str: diff --git a/avito/cpa/enums.py b/avito/cpa/enums.py deleted file mode 100644 index aeaf4b3..0000000 --- a/avito/cpa/enums.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Enum-значения раздела cpa.""" - -from __future__ import annotations - -from enum import IntEnum - - -class CpaCallStatusId(IntEnum): - """Числовой статус CPA-звонка.""" - - NEW = 0 - ACCEPTED = 1 - REJECTED = 2 - PAID = 3 - - -__all__ = ("CpaCallStatusId",) diff --git a/avito/cpa/mappers.py b/avito/cpa/mappers.py deleted file mode 100644 index 3a46997..0000000 --- a/avito/cpa/mappers.py +++ /dev/null @@ -1,263 +0,0 @@ -"""Мапперы JSON -> dataclass для пакета cpa.""" - -from __future__ import annotations - -from collections.abc import Mapping -from typing import cast - -from avito.core.exceptions import ResponseMappingError -from avito.cpa.enums import CpaCallStatusId -from avito.cpa.models import ( - CallTrackingCallInfo, - CallTrackingCallResponse, - CallTrackingCallsResult, - CpaActionResult, - CpaBalanceInfo, - CpaCallInfo, - CpaCallsResult, - CpaChatInfo, - CpaChatsResult, - CpaErrorInfo, - CpaPhoneInfo, - CpaPhonesResult, -) - -Payload = Mapping[str, object] - - -def _expect_mapping(payload: object) -> Payload: - if not isinstance(payload, Mapping): - raise ResponseMappingError("Ожидался JSON-объект.", payload=payload) - return cast(Payload, payload) - - -def _mapping(payload: Payload, *keys: str) -> Payload: - for key in keys: - value = payload.get(key) - if isinstance(value, Mapping): - return cast(Payload, value) - return {} - - -def _list(payload: Payload, *keys: str) -> list[Payload]: - for key in keys: - value = payload.get(key) - if isinstance(value, list): - return [item for item in value if isinstance(item, Mapping)] - return [] - - -def _str(payload: Payload, *keys: str) -> str | None: - for key in keys: - value = payload.get(key) - if isinstance(value, str): - return value - if isinstance(value, int) and not isinstance(value, bool): - return str(value) - return None - - -def _int(payload: Payload, *keys: str) -> int | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - continue - if isinstance(value, int): - return value - return None - - -def _float(payload: Payload, *keys: str) -> float | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - continue - if isinstance(value, (int, float)): - return float(value) - return None - - -def _bool(payload: Payload, *keys: str) -> bool | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - return value - return None - - -def _cpa_call_status_id(payload: Payload) -> CpaCallStatusId | None: - value = _int(payload, "statusId") - if value is None: - return None - try: - return CpaCallStatusId(value) - except ValueError: - return None - - -def map_cpa_error(payload: object | None) -> CpaErrorInfo | None: - """Преобразует payload ошибки CPA API.""" - - if payload is None: - return None - data = _expect_mapping(payload) - if not data: - return None - return CpaErrorInfo( - code=_int(data, "code"), - message=_str(data, "message", "error"), - ) - - -def map_cpa_action(payload: object) -> CpaActionResult: - """Преобразует результат mutation-операции CPA.""" - - data = _expect_mapping(payload) - return CpaActionResult( - success=bool(data.get("success", False)), - error=map_cpa_error(data.get("error")), - ) - - -def map_balance(payload: object) -> CpaBalanceInfo: - """Преобразует ответ баланса CPA.""" - - data = _expect_mapping(payload) - return CpaBalanceInfo( - balance=_int(data, "balance"), - advance=_int(data, "advance"), - debt=_int(data, "debt"), - error=map_cpa_error(data.get("error")), - ) - - -def _map_cpa_call(item: Payload) -> CpaCallInfo: - return CpaCallInfo( - call_id=_str(item, "id", "callId"), - item_id=_str(item, "itemId", "item_id"), - buyer_phone=_str(item, "buyerPhone"), - seller_phone=_str(item, "sellerPhone"), - virtual_phone=_str(item, "virtualPhone"), - status_id=_cpa_call_status_id(item), - price=_int(item, "price"), - duration=_int(item, "duration", "talkDuration"), - waiting_duration=_float(item, "waitingDuration"), - created_at=_str(item, "createTime", "createdAt", "callTime"), - started_at=_str(item, "startTime"), - group_title=_str(item, "groupTitle"), - record_url=_str(item, "recordUrl"), - is_arbitrage_available=_bool(item, "isArbitrageAvailable"), - ) - - -def map_call_item(payload: object) -> CpaCallInfo: - """Преобразует один звонок CPA.""" - - data = _expect_mapping(payload) - call = _mapping(data, "calls", "call") - source = call or data - return _map_cpa_call(source) - - -def map_calls(payload: object) -> CpaCallsResult: - """Преобразует список звонков CPA.""" - - data = _expect_mapping(payload) - return CpaCallsResult( - items=[_map_cpa_call(item) for item in _list(data, "calls", "items", "results")], - error=map_cpa_error(data.get("error")), - ) - - -def _map_cpa_chat(item: Payload) -> CpaChatInfo: - chat = _mapping(item, "chat") - buyer = _mapping(item, "buyer") - listing = _mapping(item, "item") - source = chat or item - return CpaChatInfo( - chat_id=_str(source, "id", "chatId"), - action_id=_str(source, "actionId", "action_id"), - item_id=_str(listing, "id", "itemId"), - item_title=_str(listing, "title", "subject"), - buyer_user_id=_str(buyer, "userId", "id"), - buyer_name=_str(buyer, "name"), - created_at=_str(source, "createdAt", "created_at"), - updated_at=_str(source, "updatedAt", "updated_at"), - is_arbitrage_available=_bool(item, "isArbitrageAvailable"), - ) - - -def map_chat_item(payload: object) -> CpaChatInfo: - """Преобразует один чат CPA.""" - - data = _expect_mapping(payload) - chat = _mapping(data, "chat") - if chat: - return _map_cpa_chat(chat) - return _map_cpa_chat(data) - - -def map_chats(payload: object) -> CpaChatsResult: - """Преобразует список чатов CPA.""" - - data = _expect_mapping(payload) - return CpaChatsResult( - items=[_map_cpa_chat(item) for item in _list(data, "chats", "items", "results")], - ) - - -def map_phones(payload: object) -> CpaPhonesResult: - """Преобразует список телефонов из целевых чатов.""" - - data = _expect_mapping(payload) - return CpaPhonesResult( - items=[ - CpaPhoneInfo( - action_id=_str(item, "id", "actionId"), - phone_number=_str(item, "phone_number", "phoneNumber"), - created_at=_str(item, "date", "createdAt"), - price=_int(item, "pricePenny", "price"), - group=_str(item, "group"), - preview_url=_str(item, "url", "previewUrl"), - ) - for item in _list(data, "results", "items") - ], - total=_int(data, "total"), - ) - - -def _map_call_tracking_call(item: Payload) -> CallTrackingCallInfo: - return CallTrackingCallInfo( - call_id=_str(item, "callId", "id"), - item_id=_str(item, "itemId"), - buyer_phone=_str(item, "buyerPhone"), - seller_phone=_str(item, "sellerPhone"), - virtual_phone=_str(item, "virtualPhone"), - call_time=_str(item, "callTime", "createTime"), - talk_duration=_int(item, "talkDuration", "duration"), - waiting_duration=_float(item, "waitingDuration"), - ) - - -def map_call_tracking_call_item(payload: object) -> CallTrackingCallResponse: - """Преобразует один звонок CallTracking.""" - - data = _expect_mapping(payload) - call = _mapping(data, "call") - error = map_cpa_error(data.get("error")) - if not call or error is None: - raise ResponseMappingError( - "Ответ CallTracking getCallById должен содержать `call` и `error`.", - payload=payload, - ) - return CallTrackingCallResponse(call=_map_call_tracking_call(call), error=error) - - -def map_call_tracking_calls(payload: object) -> CallTrackingCallsResult: - """Преобразует список звонков CallTracking.""" - - data = _expect_mapping(payload) - return CallTrackingCallsResult( - items=[_map_call_tracking_call(item) for item in _list(data, "calls", "items", "results")], - error=map_cpa_error(data.get("error")), - ) diff --git a/avito/cpa/models.py b/avito/cpa/models.py index a2addb5..20e39a0 100644 --- a/avito/cpa/models.py +++ b/avito/cpa/models.py @@ -3,85 +3,119 @@ from __future__ import annotations from base64 import b64encode +from collections.abc import Mapping from dataclasses import dataclass +from enum import IntEnum -from avito.core import BinaryResponse -from avito.core.serialization import SerializableModel -from avito.cpa.enums import CpaCallStatusId +from avito.core import ApiModel, BinaryResponse, RequestModel +from avito.core.enums import map_int_enum_or_unknown +from avito.core.exceptions import ResponseMappingError + +Payload = Mapping[str, object] + + +class CpaCallStatusId(IntEnum): + """Числовой статус CPA-звонка.""" + + UNKNOWN = -1 + NEW = 0 + ACCEPTED = 1 + REJECTED = 2 + PAID = 3 @dataclass(slots=True, frozen=True) -class CpaChatsByTimeRequest: +class CpaChatsByTimeRequest(RequestModel): """Запрос списка CPA-чатов по времени.""" created_at_from: str - limit: int | None = None + limit: int + offset: int def to_payload(self) -> dict[str, object]: - payload: dict[str, object] = {"createdAtFrom": self.created_at_from} - if self.limit is not None: - payload["limit"] = self.limit - return payload + return { + "dateTimeFrom": self.created_at_from, + "limit": self.limit, + "offset": self.offset, + } @dataclass(slots=True, frozen=True) -class CpaPhonesFromChatsRequest: +class CpaPhonesFromChatsRequest(RequestModel): """Запрос телефонов из целевых чатов.""" - action_ids: list[str] + date_time_from: str + limit: int + offset: int def to_payload(self) -> dict[str, object]: - return {"actionIds": list(self.action_ids)} + return { + "dateTimeFrom": self.date_time_from, + "limit": self.limit, + "offset": self.offset, + } @dataclass(slots=True, frozen=True) -class CpaCallsByTimeRequest: +class CpaCallsByTimeRequest(RequestModel): """Запрос списка CPA-звонков по времени.""" date_time_from: str - date_time_to: str + limit: int + offset: int | None = None def to_payload(self) -> dict[str, object]: - return { + payload: dict[str, object] = { "dateTimeFrom": self.date_time_from, - "dateTimeTo": self.date_time_to, + "limit": self.limit, } + if self.offset is not None: + payload["offset"] = self.offset + return payload + + +@dataclass(slots=True, frozen=True) +class CpaBalanceInfoRequest(RequestModel): + """Запрос CPA-баланса со string body по Swagger.""" + + def to_payload(self) -> object: + return "{}" @dataclass(slots=True, frozen=True) -class CpaCallComplaintRequest: +class CpaCallComplaintRequest(RequestModel): """Запрос жалобы на CPA-звонок.""" call_id: int reason: str def to_payload(self) -> dict[str, object]: - return {"callId": self.call_id, "reason": self.reason} + return {"callId": self.call_id, "message": self.reason} @dataclass(slots=True, frozen=True) -class CpaLeadComplaintRequest: +class CpaLeadComplaintRequest(RequestModel): """Запрос жалобы по action id.""" - action_id: str + action_id: int reason: str def to_payload(self) -> dict[str, object]: - return {"actionId": self.action_id, "reason": self.reason} + return {"actionId": self.action_id, "message": self.reason} @dataclass(slots=True, frozen=True) -class CpaCallByIdRequest: +class CpaCallByIdRequest(RequestModel): """Запрос получения CPA-звонка по идентификатору.""" call_id: int - def to_payload(self) -> dict[str, int]: + def to_payload(self) -> dict[str, object]: return {"callId": self.call_id} @dataclass(slots=True, frozen=True) -class CallTrackingCallsRequest: +class CallTrackingCallsRequest(RequestModel): """Запрос списка звонков CallTracking.""" date_time_from: str @@ -102,23 +136,40 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class CpaErrorInfo(SerializableModel): +class CpaErrorInfo(ApiModel): """Информация об ошибке CPA API.""" code: int | None message: str | None + @classmethod + def from_payload(cls, payload: object) -> CpaErrorInfo: + """Преобразует payload ошибки CPA API.""" + + data = _expect_mapping(payload) + return cls(code=_int(data, "code"), message=_str(data, "message", "error")) + @dataclass(slots=True, frozen=True) -class CpaActionResult(SerializableModel): +class CpaActionResult(ApiModel): """Результат mutation-операции CPA.""" success: bool error: CpaErrorInfo | None = None + @classmethod + def from_payload(cls, payload: object) -> CpaActionResult: + """Преобразует результат mutation-операции CPA.""" + + data = _expect_mapping(payload) + return cls( + success=bool(data.get("success", False)), + error=_map_cpa_error(data.get("error")), + ) + @dataclass(slots=True, frozen=True) -class CpaBalanceInfo(SerializableModel): +class CpaBalanceInfo(ApiModel): """Информация о CPA-балансе пользователя.""" balance: int | None @@ -126,9 +177,21 @@ class CpaBalanceInfo(SerializableModel): debt: int | None = None error: CpaErrorInfo | None = None + @classmethod + def from_payload(cls, payload: object) -> CpaBalanceInfo: + """Преобразует ответ баланса CPA.""" + + data = _expect_mapping(payload) + return cls( + balance=_int(data, "balance"), + advance=_int(data, "advance"), + debt=_int(data, "debt"), + error=_map_cpa_error(data.get("error")), + ) + @dataclass(slots=True, frozen=True) -class CpaCallInfo(SerializableModel): +class CpaCallInfo(ApiModel): """Информация о звонке CPA.""" call_id: str | None @@ -146,17 +209,35 @@ class CpaCallInfo(SerializableModel): record_url: str | None is_arbitrage_available: bool | None + @classmethod + def from_payload(cls, payload: object) -> CpaCallInfo: + """Преобразует один звонок CPA.""" + + data = _expect_mapping(payload) + call = _mapping(data, "calls", "call") + return _map_cpa_call(call or data) + @dataclass(slots=True, frozen=True) -class CpaCallsResult(SerializableModel): +class CpaCallsResult(ApiModel): """Список звонков CPA.""" items: list[CpaCallInfo] error: CpaErrorInfo | None = None + @classmethod + def from_payload(cls, payload: object) -> CpaCallsResult: + """Преобразует список звонков CPA.""" + + data = _expect_mapping(payload) + return cls( + items=[_map_cpa_call(item) for item in _list(data, "calls", "items", "results")], + error=_map_cpa_error(data.get("error")), + ) + @dataclass(slots=True, frozen=True) -class CpaChatInfo(SerializableModel): +class CpaChatInfo(ApiModel): """Информация о CPA-чате.""" chat_id: str | None @@ -169,16 +250,35 @@ class CpaChatInfo(SerializableModel): updated_at: str | None is_arbitrage_available: bool | None + @classmethod + def from_payload(cls, payload: object) -> CpaChatInfo: + """Преобразует один чат CPA.""" + + data = _expect_mapping(payload) + chat = _mapping(data, "chat") + if chat: + return _map_cpa_chat(chat) + return _map_cpa_chat(data) + @dataclass(slots=True, frozen=True) -class CpaChatsResult(SerializableModel): +class CpaChatsResult(ApiModel): """Список чатов CPA.""" items: list[CpaChatInfo] + @classmethod + def from_payload(cls, payload: object) -> CpaChatsResult: + """Преобразует список чатов CPA.""" + + data = _expect_mapping(payload) + return cls( + items=[_map_cpa_chat(item) for item in _list(data, "chats", "items", "results")], + ) + @dataclass(slots=True, frozen=True) -class CpaPhoneInfo(SerializableModel): +class CpaPhoneInfo(ApiModel): """Информация по телефону, найденному в целевом чате.""" action_id: str | None @@ -190,12 +290,32 @@ class CpaPhoneInfo(SerializableModel): @dataclass(slots=True, frozen=True) -class CpaPhonesResult(SerializableModel): +class CpaPhonesResult(ApiModel): """Список телефонных номеров из целевых чатов.""" items: list[CpaPhoneInfo] total: int | None = None + @classmethod + def from_payload(cls, payload: object) -> CpaPhonesResult: + """Преобразует список телефонов из целевых чатов.""" + + data = _expect_mapping(payload) + return cls( + items=[ + CpaPhoneInfo( + action_id=_str(item, "id", "actionId"), + phone_number=_str(item, "phone_number", "phoneNumber"), + created_at=_str(item, "date", "createdAt"), + price=_int(item, "pricePenny", "price"), + group=_str(item, "group"), + preview_url=_str(item, "url", "previewUrl"), + ) + for item in _list(data, "results", "items") + ], + total=_int(data, "total"), + ) + @dataclass(slots=True, frozen=True) class CpaAudioRecord: @@ -223,7 +343,7 @@ def model_dump(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class CallTrackingCallInfo(SerializableModel): +class CallTrackingCallInfo(ApiModel): """Информация о звонке CallTracking.""" call_id: str | None @@ -237,32 +357,58 @@ class CallTrackingCallInfo(SerializableModel): @dataclass(slots=True, frozen=True) -class CallTrackingCallsResult(SerializableModel): +class CallTrackingCallsResult(ApiModel): """Список звонков CallTracking.""" items: list[CallTrackingCallInfo] error: CpaErrorInfo | None = None + @classmethod + def from_payload(cls, payload: object) -> CallTrackingCallsResult: + """Преобразует список звонков CallTracking.""" + + data = _expect_mapping(payload) + return cls( + items=[ + _map_call_tracking_call(item) for item in _list(data, "calls", "items", "results") + ], + error=_map_cpa_error(data.get("error")), + ) + @dataclass(slots=True, frozen=True) -class CallTrackingGetCallByIdRequest: +class CallTrackingGetCallByIdRequest(RequestModel): """Запрос получения звонка CallTracking по идентификатору.""" call_id: int - def to_payload(self) -> dict[str, int]: + def to_payload(self) -> dict[str, object]: """Сериализует запрос звонка CallTracking.""" return {"callId": self.call_id} @dataclass(slots=True, frozen=True) -class CallTrackingCallResponse(SerializableModel): +class CallTrackingCallResponse(ApiModel): """Ответ CallTracking get_call_by_id с объектом звонка и ошибкой.""" call: CallTrackingCallInfo error: CpaErrorInfo + @classmethod + def from_payload(cls, payload: object) -> CallTrackingCallResponse: + """Преобразует один звонок CallTracking.""" + + data = _expect_mapping(payload) + call = _mapping(data, "call") + error = _map_cpa_error(data.get("error")) + if not call or error is None: + raise ResponseMappingError( + "Ответ CallTracking getCallById должен содержать `call` и `error`.", + payload=payload, + ) + return cls(call=_map_call_tracking_call(call), error=error) + @dataclass(slots=True, frozen=True) class CallTrackingRecord: @@ -288,3 +434,156 @@ def to_dict(self) -> dict[str, object]: def model_dump(self) -> dict[str, object]: return self.to_dict() + +def _expect_mapping(payload: object) -> Payload: + if not isinstance(payload, Mapping): + raise ResponseMappingError("Ожидался JSON-объект.", payload=payload) + return payload + + +def _mapping(payload: Payload, *keys: str) -> Payload: + for key in keys: + value = payload.get(key) + if isinstance(value, Mapping): + return value + return {} + + +def _list(payload: Payload, *keys: str) -> list[Payload]: + for key in keys: + value = payload.get(key) + if isinstance(value, list): + return [item for item in value if isinstance(item, Mapping)] + return [] + + +def _str(payload: Payload, *keys: str) -> str | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str): + return value + if isinstance(value, int) and not isinstance(value, bool): + return str(value) + return None + + +def _int(payload: Payload, *keys: str) -> int | None: + for key in keys: + value = payload.get(key) + if isinstance(value, bool): + continue + if isinstance(value, int): + return value + return None + + +def _float(payload: Payload, *keys: str) -> float | None: + for key in keys: + value = payload.get(key) + if isinstance(value, bool): + continue + if isinstance(value, int | float): + return float(value) + return None + + +def _bool(payload: Payload, *keys: str) -> bool | None: + for key in keys: + value = payload.get(key) + if isinstance(value, bool): + return value + return None + + +def _cpa_call_status_id(payload: Payload) -> CpaCallStatusId | None: + return map_int_enum_or_unknown( + _int(payload, "statusId"), + CpaCallStatusId, + enum_name="cpa.call_status_id", + ) + + +def _map_cpa_error(payload: object | None) -> CpaErrorInfo | None: + if payload is None: + return None + data = _expect_mapping(payload) + if not data: + return None + return CpaErrorInfo.from_payload(data) + + +def _map_cpa_call(item: Payload) -> CpaCallInfo: + return CpaCallInfo( + call_id=_str(item, "id", "callId"), + item_id=_str(item, "itemId", "item_id"), + buyer_phone=_str(item, "buyerPhone"), + seller_phone=_str(item, "sellerPhone"), + virtual_phone=_str(item, "virtualPhone"), + status_id=_cpa_call_status_id(item), + price=_int(item, "price"), + duration=_int(item, "duration", "talkDuration"), + waiting_duration=_float(item, "waitingDuration"), + created_at=_str(item, "createTime", "createdAt", "callTime"), + started_at=_str(item, "startTime"), + group_title=_str(item, "groupTitle"), + record_url=_str(item, "recordUrl"), + is_arbitrage_available=_bool(item, "isArbitrageAvailable"), + ) + + +def _map_cpa_chat(item: Payload) -> CpaChatInfo: + chat = _mapping(item, "chat") + buyer = _mapping(item, "buyer") + listing = _mapping(item, "item") + source = chat or item + return CpaChatInfo( + chat_id=_str(source, "id", "chatId"), + action_id=_str(source, "actionId", "action_id"), + item_id=_str(listing, "id", "itemId"), + item_title=_str(listing, "title", "subject"), + buyer_user_id=_str(buyer, "userId", "id"), + buyer_name=_str(buyer, "name"), + created_at=_str(source, "createdAt", "created_at"), + updated_at=_str(source, "updatedAt", "updated_at"), + is_arbitrage_available=_bool(item, "isArbitrageAvailable"), + ) + + +def _map_call_tracking_call(item: Payload) -> CallTrackingCallInfo: + return CallTrackingCallInfo( + call_id=_str(item, "callId", "id"), + item_id=_str(item, "itemId"), + buyer_phone=_str(item, "buyerPhone"), + seller_phone=_str(item, "sellerPhone"), + virtual_phone=_str(item, "virtualPhone"), + call_time=_str(item, "callTime", "createTime"), + talk_duration=_int(item, "talkDuration", "duration"), + waiting_duration=_float(item, "waitingDuration"), + ) + + +__all__ = ( + "CallTrackingCallInfo", + "CallTrackingCallResponse", + "CallTrackingCallsRequest", + "CallTrackingCallsResult", + "CallTrackingGetCallByIdRequest", + "CallTrackingRecord", + "CpaActionResult", + "CpaAudioRecord", + "CpaBalanceInfo", + "CpaCallByIdRequest", + "CpaCallComplaintRequest", + "CpaCallInfo", + "CpaCallStatusId", + "CpaCallsByTimeRequest", + "CpaCallsResult", + "CpaChatInfo", + "CpaChatsByTimeRequest", + "CpaChatsResult", + "CpaErrorInfo", + "CpaLeadComplaintRequest", + "CpaPhoneInfo", + "CpaPhonesFromChatsRequest", + "CpaPhonesResult", +) diff --git a/avito/cpa/operations.py b/avito/cpa/operations.py new file mode 100644 index 0000000..909aa96 --- /dev/null +++ b/avito/cpa/operations.py @@ -0,0 +1,152 @@ +"""Operation specs for CPA and CallTracking domains.""" + +from __future__ import annotations + +from avito.core import BinaryResponse, OperationSpec +from avito.cpa.models import ( + CallTrackingCallResponse, + CallTrackingCallsRequest, + CallTrackingCallsResult, + CallTrackingGetCallByIdRequest, + CpaActionResult, + CpaBalanceInfo, + CpaBalanceInfoRequest, + CpaCallByIdRequest, + CpaCallComplaintRequest, + CpaCallInfo, + CpaCallsByTimeRequest, + CpaCallsResult, + CpaChatInfo, + CpaChatsByTimeRequest, + CpaChatsResult, + CpaLeadComplaintRequest, + CpaPhonesFromChatsRequest, + CpaPhonesResult, +) + +CPA_HEADERS = {"X-Source": "avito-py"} + +GET_CPA_CHAT_BY_ACTION_ID = OperationSpec( + name="cpa.chats.get_by_action_id", + method="GET", + path="/cpa/v1/chatByActionId/{actionId}", + response_model=CpaChatInfo, +) +LIST_CPA_CHATS_CLASSIC = OperationSpec( + name="cpa.chats.list_by_time_classic", + method="POST", + path="/cpa/v1/chatsByTime", + request_model=CpaChatsByTimeRequest, + response_model=CpaChatsResult, + retry_mode="enabled", +) +LIST_CPA_CHATS = OperationSpec( + name="cpa.chats.list_by_time", + method="POST", + path="/cpa/v2/chatsByTime", + request_model=CpaChatsByTimeRequest, + response_model=CpaChatsResult, + retry_mode="enabled", +) +GET_CPA_PHONES_INFO = OperationSpec( + name="cpa.chats.get_phones_info", + method="POST", + path="/cpa/v1/phonesInfoFromChats", + request_model=CpaPhonesFromChatsRequest, + response_model=CpaPhonesResult, + retry_mode="enabled", +) +LIST_CPA_CALLS = OperationSpec( + name="cpa.calls.list_by_time", + method="POST", + path="/cpa/v2/callsByTime", + request_model=CpaCallsByTimeRequest, + response_model=CpaCallsResult, + retry_mode="enabled", +) +CREATE_CPA_CALL_COMPLAINT = OperationSpec( + name="cpa.calls.create_complaint", + method="POST", + path="/cpa/v1/createComplaint", + request_model=CpaCallComplaintRequest, + response_model=CpaActionResult, + retry_mode="enabled", +) +CREATE_CPA_LEAD_COMPLAINT = OperationSpec( + name="cpa.leads.create_complaint_by_action_id", + method="POST", + path="/cpa/v1/createComplaintByActionId", + request_model=CpaLeadComplaintRequest, + response_model=CpaActionResult, + retry_mode="enabled", +) +GET_CPA_BALANCE = OperationSpec( + name="cpa.leads.get_balance_info", + method="POST", + path="/cpa/v3/balanceInfo", + request_model=CpaBalanceInfoRequest, + response_model=CpaBalanceInfo, + retry_mode="enabled", +) +GET_CPA_ARCHIVE_RECORD: OperationSpec[BinaryResponse] = OperationSpec( + name="cpa.archive.get_record", + method="GET", + path="/cpa/v1/call/{call_id}", + response_kind="binary", +) +GET_CPA_ARCHIVE_BALANCE = OperationSpec( + name="cpa.archive.get_balance_info", + method="POST", + path="/cpa/v2/balanceInfo", + request_model=CpaBalanceInfoRequest, + response_model=CpaBalanceInfo, + retry_mode="enabled", +) +GET_CPA_ARCHIVE_CALL_BY_ID = OperationSpec( + name="cpa.archive.get_call_by_id", + method="POST", + path="/cpa/v2/callById", + request_model=CpaCallByIdRequest, + response_model=CpaCallInfo, + retry_mode="enabled", +) +GET_CALLTRACKING_CALL_BY_ID = OperationSpec( + name="cpa.calltracking.get_call_by_id", + method="POST", + path="/calltracking/v1/getCallById/", + request_model=CallTrackingGetCallByIdRequest, + response_model=CallTrackingCallResponse, + retry_mode="enabled", +) +GET_CALLTRACKING_CALLS = OperationSpec( + name="cpa.calltracking.get_calls", + method="POST", + path="/calltracking/v1/getCalls/", + request_model=CallTrackingCallsRequest, + response_model=CallTrackingCallsResult, + retry_mode="enabled", +) +GET_CALLTRACKING_RECORD: OperationSpec[BinaryResponse] = OperationSpec( + name="cpa.calltracking.get_record_by_call_id", + method="GET", + path="/calltracking/v1/getRecordByCallId/", + response_kind="binary", +) + +__all__ = ( + "CPA_HEADERS", + "CREATE_CPA_CALL_COMPLAINT", + "CREATE_CPA_LEAD_COMPLAINT", + "GET_CALLTRACKING_CALLS", + "GET_CALLTRACKING_CALL_BY_ID", + "GET_CALLTRACKING_RECORD", + "GET_CPA_ARCHIVE_BALANCE", + "GET_CPA_ARCHIVE_CALL_BY_ID", + "GET_CPA_ARCHIVE_RECORD", + "GET_CPA_BALANCE", + "GET_CPA_CHAT_BY_ACTION_ID", + "GET_CPA_PHONES_INFO", + "LIST_CPA_CALLS", + "LIST_CPA_CHATS", + "LIST_CPA_CHATS_CLASSIC", +) diff --git a/avito/jobs/__init__.py b/avito/jobs/__init__.py index 28b6b88..69a3491 100644 --- a/avito/jobs/__init__.py +++ b/avito/jobs/__init__.py @@ -1,41 +1,40 @@ """Пакет jobs.""" from avito.jobs.domain import Application, JobDictionary, JobWebhook, Resume, Vacancy -from avito.jobs.enums import ( - ApplicationStatus, - JobActionStatus, - JobEnrichmentStatus, - JobMatchingStatus, - VacancyModerationStatus, - VacancyStatus, -) from avito.jobs.models import ( ApplicationActionRequest, - ApplicationIdsQuery, ApplicationIdsRequest, ApplicationIdsResult, ApplicationsResult, ApplicationStatesResult, + ApplicationStatus, ApplicationViewedItem, ApplicationViewedRequest, JobActionResult, + JobActionStatus, JobDictionariesResult, JobDictionaryValuesResult, + JobEnrichmentStatus, + JobMatchingStatus, JobWebhookInfo, JobWebhooksResult, JobWebhookUpdateRequest, ResumeContactInfo, ResumeInfo, - ResumeSearchQuery, ResumesResult, - VacanciesQuery, VacanciesResult, VacancyArchiveRequest, VacancyAutoRenewalRequest, + VacancyBillingType, VacancyCreateRequest, + VacancyEmployment, + VacancyExperience, VacancyIdsRequest, VacancyInfo, + VacancyModerationStatus, VacancyProlongateRequest, + VacancySchedule, + VacancyStatus, VacancyStatusesResult, VacancyUpdateRequest, ) @@ -44,7 +43,6 @@ "Application", "ApplicationActionRequest", "ApplicationIdsResult", - "ApplicationIdsQuery", "ApplicationIdsRequest", "ApplicationsResult", "ApplicationStatus", @@ -65,19 +63,21 @@ "Resume", "ResumeContactInfo", "ResumeInfo", - "ResumeSearchQuery", "ResumesResult", "VacanciesResult", "Vacancy", "VacancyArchiveRequest", "VacancyAutoRenewalRequest", + "VacancyBillingType", "VacancyCreateRequest", + "VacancyEmployment", + "VacancyExperience", "VacancyInfo", "VacancyIdsRequest", "VacancyModerationStatus", "VacancyProlongateRequest", + "VacancySchedule", "VacancyStatusesResult", - "VacanciesQuery", "VacancyUpdateRequest", "VacancyStatus", ) diff --git a/avito/jobs/client.py b/avito/jobs/client.py deleted file mode 100644 index 9716b39..0000000 --- a/avito/jobs/client.py +++ /dev/null @@ -1,421 +0,0 @@ -"""Внутренние section clients для пакета jobs.""" - -from __future__ import annotations - -from collections.abc import Sequence -from dataclasses import dataclass - -from avito.core import RequestContext, Transport -from avito.core.mapping import request_public_model -from avito.jobs.mappers import ( - map_application_ids, - map_application_states, - map_applications, - map_job_action, - map_job_dictionaries, - map_job_dictionary_values, - map_job_webhook, - map_job_webhooks, - map_resume_contacts, - map_resume_item, - map_resumes, - map_vacancies, - map_vacancy_item, - map_vacancy_statuses, -) -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, -) - - -@dataclass(slots=True, frozen=True) -class ApplicationsClient: - """Выполняет HTTP-операции откликов.""" - - transport: Transport - - 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", - ApplicationActionRequest(ids=ids, action=action), - idempotency_key=idempotency_key, - ) - - 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=ApplicationIdsRequest(ids=ids).to_payload(), - ) - - def get_ids(self, *, query: ApplicationIdsQuery) -> ApplicationIdsResult: - return request_public_model( - self.transport, - "GET", - "/job/v1/applications/get_ids", - context=RequestContext("jobs.applications.get_ids"), - mapper=map_application_ids, - params=query.to_params(), - ) - - def get_states(self) -> ApplicationStatesResult: - return request_public_model( - self.transport, - "GET", - "/job/v1/applications/get_states", - context=RequestContext("jobs.applications.get_states"), - mapper=map_application_states, - ) - - 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", - ApplicationViewedRequest(applies=applies), - idempotency_key=idempotency_key, - ) - - def _post_action( - self, - 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=idempotency_key is not None), - mapper=map_job_action, - json_body=request.to_payload(), - idempotency_key=idempotency_key, - ) - - -@dataclass(slots=True, frozen=True) -class WebhookClient: - """Выполняет HTTP-операции webhook откликов.""" - - transport: Transport - - def get_webhook(self) -> JobWebhookInfo: - return request_public_model( - self.transport, - "GET", - "/job/v1/applications/webhook", - context=RequestContext("jobs.webhook.get"), - mapper=map_job_webhook, - ) - - 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=idempotency_key is not None), - mapper=map_job_webhook, - json_body=JobWebhookUpdateRequest(url=url).to_payload(), - idempotency_key=idempotency_key, - ) - - 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=idempotency_key is not None), - mapper=map_job_action, - params={"url": url}, - idempotency_key=idempotency_key, - ) - - def list_webhooks(self) -> JobWebhooksResult: - return request_public_model( - self.transport, - "GET", - "/job/v1/applications/webhooks", - context=RequestContext("jobs.webhook.list"), - mapper=map_job_webhooks, - ) - - -@dataclass(slots=True, frozen=True) -class ResumeClient: - """Выполняет HTTP-операции резюме.""" - - transport: Transport - - def search(self, *, query: ResumeSearchQuery | None = None) -> ResumesResult: - return request_public_model( - self.transport, - "GET", - "/job/v1/resumes/", - context=RequestContext("jobs.resumes.search"), - mapper=map_resumes, - params=query.to_params() if query is not None else None, - ) - - def get_contacts(self, *, resume_id: str) -> ResumeContactInfo: - return request_public_model( - self.transport, - "GET", - f"/job/v1/resumes/{resume_id}/contacts/", - context=RequestContext("jobs.resumes.get_contacts"), - mapper=map_resume_contacts, - ) - - def get_item(self, *, resume_id: str) -> ResumeInfo: - return request_public_model( - self.transport, - "GET", - f"/job/v2/resumes/{resume_id}", - context=RequestContext("jobs.resumes.get_item"), - mapper=map_resume_item, - ) - - -@dataclass(slots=True, frozen=True) -class VacanciesClient: - """Выполняет HTTP-операции вакансий.""" - - transport: Transport - - 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=idempotency_key is not None, - ), - mapper=map_job_action, - json_body=VacancyCreateRequest(title=title).to_payload(), - idempotency_key=idempotency_key, - ) - - def archive( - self, - *, - vacancy_id: int | str, - 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=idempotency_key is not None), - mapper=map_job_action, - json_body=VacancyArchiveRequest(employee_id=employee_id).to_payload(), - idempotency_key=idempotency_key, - ) - - def update_classic( - self, - *, - vacancy_id: int | str, - 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=idempotency_key is not None, - ), - mapper=map_job_action, - json_body=VacancyUpdateRequest(title=title).to_payload(), - idempotency_key=idempotency_key, - ) - - def prolongate( - self, - *, - vacancy_id: int | str, - 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=idempotency_key is not None, - ), - mapper=map_job_action, - json_body=VacancyProlongateRequest(billing_type=billing_type).to_payload(), - idempotency_key=idempotency_key, - ) - - def list(self, *, query: VacanciesQuery | None = None) -> VacanciesResult: - return request_public_model( - self.transport, - "GET", - "/job/v2/vacancies", - context=RequestContext("jobs.vacancies.list"), - mapper=map_vacancies, - params=query.to_params() if query is not None else None, - ) - - 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=idempotency_key is not None), - mapper=map_job_action, - json_body=VacancyCreateRequest(title=title).to_payload(), - idempotency_key=idempotency_key, - ) - - 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=VacancyIdsRequest(ids=list(ids)).to_payload(), - ) - - 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=VacancyIdsRequest(ids=list(ids)).to_payload(), - ) - - 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=idempotency_key is not None), - mapper=map_job_action, - json_body=VacancyUpdateRequest(title=title).to_payload(), - idempotency_key=idempotency_key, - ) - - def get_item( - self, *, vacancy_id: int | str, query: VacanciesQuery | None = None - ) -> VacancyInfo: - return request_public_model( - self.transport, - "GET", - f"/job/v2/vacancies/{vacancy_id}", - context=RequestContext("jobs.vacancies.get_item"), - mapper=map_vacancy_item, - params=query.to_params() if query is not None else None, - ) - - def update_auto_renewal( - self, - *, - vacancy_uuid: str, - 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=idempotency_key is not None, - ), - mapper=map_job_action, - json_body=VacancyAutoRenewalRequest(auto_renewal=auto_renewal).to_payload(), - idempotency_key=idempotency_key, - ) - - -@dataclass(slots=True, frozen=True) -class DictionariesClient: - """Выполняет HTTP-операции словарей вакансий.""" - - transport: Transport - - def list_dicts(self) -> JobDictionariesResult: - return request_public_model( - self.transport, - "GET", - "/job/v2/vacancy/dict", - context=RequestContext("jobs.dictionaries.list_dicts"), - mapper=map_job_dictionaries, - ) - - def get_dict_by_id(self, *, dictionary_id: str) -> JobDictionaryValuesResult: - return request_public_model( - self.transport, - "GET", - f"/job/v2/vacancy/dict/{dictionary_id}", - context=RequestContext("jobs.dictionaries.get_dict_by_id"), - mapper=map_job_dictionary_values, - ) - - -__all__ = ( - "ApplicationsClient", - "DictionariesClient", - "ResumeClient", - "VacanciesClient", - "WebhookClient", -) diff --git a/avito/jobs/domain.py b/avito/jobs/domain.py index cdafb76..2b2ac87 100644 --- a/avito/jobs/domain.py +++ b/avito/jobs/domain.py @@ -5,35 +5,73 @@ from collections.abc import Sequence from dataclasses import dataclass -from avito.core import ValidationError +from avito.core import ApiTimeouts, RetryOverride, ValidationError from avito.core.domain import DomainObject from avito.core.swagger import swagger_operation -from avito.jobs.client import ( - ApplicationsClient, - DictionariesClient, - ResumeClient, - VacanciesClient, - WebhookClient, -) +from avito.core.validation import DateInput, serialize_iso_datetime from avito.jobs.models import ( + ApplicationActionRequest, ApplicationIdsQuery, + ApplicationIdsRequest, ApplicationIdsResult, ApplicationsResult, ApplicationStatesResult, ApplicationViewedItem, + ApplicationViewedRequest, + ApplicationViewedRequestItem, JobActionResult, JobDictionariesResult, JobDictionaryValuesResult, JobWebhookInfo, JobWebhooksResult, + JobWebhookUpdateRequest, ResumeContactInfo, ResumeInfo, ResumeSearchQuery, ResumesResult, VacanciesQuery, VacanciesResult, + VacancyArchiveRequest, + VacancyAutoRenewalRequest, + VacancyBillingTypeInput, + VacancyClassicCreateRequest, + VacancyClassicUpdateRequest, + VacancyCreateRequest, + VacancyEmploymentInput, + VacancyExperienceInput, + VacancyIdsRequest, VacancyInfo, + VacancyProlongateRequest, + VacancyScheduleInput, VacancyStatusesResult, + VacancyUpdateRequest, +) +from avito.jobs.operations import ( + APPLY_APPLICATION_ACTIONS, + ARCHIVE_VACANCY, + CREATE_VACANCY, + CREATE_VACANCY_CLASSIC, + DELETE_JOB_WEBHOOK, + GET_APPLICATION_IDS, + GET_APPLICATION_STATES, + GET_APPLICATIONS_BY_IDS, + GET_JOB_DICTIONARY, + GET_JOB_WEBHOOK, + GET_RESUME, + GET_RESUME_CONTACTS, + GET_VACANCIES_BY_IDS, + GET_VACANCY, + GET_VACANCY_STATUSES, + LIST_JOB_DICTIONARIES, + LIST_JOB_WEBHOOKS, + LIST_VACANCIES, + PROLONGATE_VACANCY, + SEARCH_RESUMES, + SET_APPLICATIONS_IS_VIEWED, + UPDATE_JOB_WEBHOOK, + UPDATE_VACANCY, + UPDATE_VACANCY_AUTO_RENEWAL, + UPDATE_VACANCY_CLASSIC, ) @@ -53,52 +91,142 @@ class Vacancy(DomainObject): "/job/v2/vacancies", spec="АвитоРабота.json", operation_id="vacancyCreateV2", - method_args={"title": "body.title"}, + method_args={"title": "body.title", "billing_type": "body.billing_type"}, ) def create( self, *, title: str, + billing_type: VacancyBillingTypeInput, + description: str | None = None, + business_area: int | None = None, + employment: VacancyEmploymentInput | None = None, + schedule: VacancyScheduleInput | None = None, + experience: VacancyExperienceInput | None = None, version: int = 2, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> JobActionResult: - """Выполняет публичную операцию `Vacancy.create` и возвращает типизированную SDK-модель. - - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. - - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + """Создает вакансию. + + Аргументы: + title: передает название вакансии. + billing_type: задает тип биллинга. + description: передает описание вакансии для legacy v1 operation. + business_area: задает сферу деятельности для legacy v1 operation. + employment: задает тип занятости для legacy v1 operation. + schedule: задает режим работы для legacy v1 operation. + experience: задает требуемый опыт для legacy v1 operation. + version: задает версию upstream-контракта, если операция ее поддерживает. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - client = VacanciesClient(self.transport) if version == 1: - return self.create_classic(title=title, idempotency_key=idempotency_key) - return client.create(title=title, idempotency_key=idempotency_key) + if ( + description is None + or business_area is None + or employment is None + or schedule is None + or experience is None + ): + raise ValidationError("Для создания вакансии v1 требуются поля Swagger.") + return self.create_classic( + title=title, + description=description, + billing_type=billing_type, + business_area=business_area, + employment=employment, + schedule=schedule, + experience=experience, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return self._execute( + CREATE_VACANCY, + request=VacancyCreateRequest(title=title, billing_type=billing_type), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) @swagger_operation( "POST", "/job/v1/vacancies", spec="АвитоРабота.json", operation_id="vacancyCreate", - method_args={"title": "body.name"}, + method_args={ + "title": "body.name", + "description": "body.description", + "billing_type": "body.billing_type", + "business_area": "body.business_area", + "employment": "body.employment", + "schedule": "body.schedule.id", + "experience": "body.experience", + }, ) def create_classic( self, *, title: str, + description: str, + billing_type: VacancyBillingTypeInput, + business_area: int, + employment: VacancyEmploymentInput, + schedule: VacancyScheduleInput, + experience: VacancyExperienceInput, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> JobActionResult: - """Создаёт вакансию через legacy v1 operation и возвращает типизированную SDK-модель. - - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + """Создает вакансию через legacy v1 operation. + + Аргументы: + title: передает название вакансии в Swagger поле `name`. + description: передает описание вакансии. + billing_type: задает тип биллинга. + business_area: задает сферу деятельности. + employment: задает тип занятости. + schedule: задает режим работы. + experience: задает требуемый опыт. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return VacanciesClient(self.transport).create_classic( - title=title, + return self._execute( + CREATE_VACANCY_CLASSIC, + request=VacancyClassicCreateRequest( + title=title, + description=description, + billing_type=billing_type, + business_area=business_area, + employment=employment, + schedule=schedule, + experience=experience, + ), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -106,37 +234,59 @@ def create_classic( "/job/v2/vacancies/update/{vacancy_uuid}", spec="АвитоРабота.json", operation_id="vacancyUpdateV2", - method_args={"title": "body.title"}, + method_args={"title": "body.title", "billing_type": "body.billing_type"}, ) def update( self, *, title: str, + billing_type: VacancyBillingTypeInput, vacancy_id: int | str | None = None, vacancy_uuid: str | None = None, version: int = 2, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> JobActionResult: - """Выполняет публичную операцию `Vacancy.update` и возвращает типизированную SDK-модель. - - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. - - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + """Обновляет вакансию. + + Аргументы: + title: передает название вакансии. + billing_type: задает тип биллинга. + vacancy_id: идентифицирует вакансию. + vacancy_uuid: идентифицирует вакансию по UUID. + version: задает версию upstream-контракта, если операция ее поддерживает. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - client = VacanciesClient(self.transport) if version == 1: return self.update_classic( vacancy_id=vacancy_id or self._require_vacancy_id(), title=title, + billing_type=billing_type, idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) - return client.update( - vacancy_uuid=vacancy_uuid or self._require_vacancy_id(), - title=title, + return self._execute( + UPDATE_VACANCY, + path_params={"vacancy_uuid": vacancy_uuid or self._require_vacancy_id()}, + request=VacancyUpdateRequest(title=title, billing_type=billing_type), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -144,26 +294,42 @@ def update( "/job/v1/vacancies/{vacancy_id}", spec="АвитоРабота.json", operation_id="vacancyUpdate", - method_args={"title": "body.name"}, + method_args={"title": "body.name", "billing_type": "body.billing_type"}, ) def update_classic( self, *, title: str, + billing_type: VacancyBillingTypeInput, vacancy_id: int | str | None = None, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> JobActionResult: - """Обновляет вакансию через legacy v1 operation и возвращает типизированную SDK-модель. + """Обновляет вакансию через legacy v1 operation. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + title: передает название вакансии в Swagger поле `name`. + billing_type: задает тип биллинга. + vacancy_id: идентифицирует вакансию. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return VacanciesClient(self.transport).update_classic( - vacancy_id=vacancy_id or self._require_vacancy_id(), - title=title, + return self._execute( + UPDATE_VACANCY_CLASSIC, + path_params={"vacancy_id": vacancy_id or self._require_vacancy_id()}, + request=VacancyClassicUpdateRequest(title=title, billing_type=billing_type), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -179,20 +345,36 @@ def delete( employee_id: int, vacancy_id: int | str | None = None, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> JobActionResult: - """Выполняет публичную операцию `Vacancy.delete` и возвращает типизированную SDK-модель. + """Удаляет вакансию. + + Аргументы: + employee_id: идентифицирует сотрудника аккаунта. + vacancy_id: идентифицирует вакансию. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `JobActionResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return VacanciesClient(self.transport).archive( - vacancy_id=vacancy_id or self._require_vacancy_id(), - employee_id=employee_id, + return self._execute( + ARCHIVE_VACANCY, + path_params={"vacancy_id": vacancy_id or self._require_vacancy_id()}, + request=VacancyArchiveRequest(employee_id=employee_id), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -205,23 +387,39 @@ def delete( def prolongate( self, *, - billing_type: str, + billing_type: VacancyBillingTypeInput, vacancy_id: int | str | None = None, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> JobActionResult: - """Выполняет публичную операцию `Vacancy.prolongate` и возвращает типизированную SDK-модель. + """Продлевает вакансий. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + billing_type: задает тип биллинга для продления вакансии. + vacancy_id: идентифицирует вакансию. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `JobActionResult` со статусом выполнения операции. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return VacanciesClient(self.transport).prolongate( - vacancy_id=vacancy_id or self._require_vacancy_id(), - billing_type=billing_type, + return self._execute( + PROLONGATE_VACANCY, + path_params={"vacancy_id": vacancy_id or self._require_vacancy_id()}, + request=VacancyProlongateRequest(billing_type=billing_type), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -230,15 +428,36 @@ def prolongate( spec="АвитоРабота.json", operation_id="searchVacancy", ) - def list(self, *, query: VacanciesQuery | None = None) -> VacanciesResult: - """Выполняет публичную операцию `Vacancy.list` и возвращает типизированную SDK-модель. + def list( + self, + *, + query: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> VacanciesResult: + """Возвращает список вакансий. + + Аргументы: + query: передает поисковую строку или фильтр upstream API. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `VacanciesResult` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return VacanciesClient(self.transport).list(query=query) + return self._execute( + LIST_VACANCIES, + query=VacanciesQuery(query=query), + timeout=timeout, + retry=retry, + ) @swagger_operation( "GET", @@ -247,18 +466,37 @@ def list(self, *, query: VacanciesQuery | None = None) -> VacanciesResult: operation_id="vacancyGetItem", ) def get( - self, *, vacancy_id: int | str | None = None, query: VacanciesQuery | None = None + self, + *, + vacancy_id: int | str | None = None, + query: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> VacancyInfo: - """Выполняет публичную операцию `Vacancy.get` и возвращает типизированную SDK-модель. + """Возвращает вакансий. + + Аргументы: + vacancy_id: идентифицирует вакансию. + query: передает поисковую строку или фильтр upstream API. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `VacancyInfo` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return VacanciesClient(self.transport).get_item( - vacancy_id=vacancy_id or self._require_vacancy_id(), - query=query, + return self._execute( + GET_VACANCY, + path_params={"vacancy_id": vacancy_id or self._require_vacancy_id()}, + query=VacanciesQuery(query=query), + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -268,15 +506,36 @@ def get( operation_id="vacanciesGetByIds", method_args={"ids": "body.ids"}, ) - def get_by_ids(self, *, ids: Sequence[int]) -> VacanciesResult: - """Выполняет публичную операцию `Vacancy.get_by_ids` и возвращает типизированную SDK-модель. + def get_by_ids( + self, + *, + ids: Sequence[int], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> VacanciesResult: + """Возвращает вакансий. + + Аргументы: + ids: передает идентификаторы объектов для пакетной операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `VacanciesResult` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return VacanciesClient(self.transport).get_by_ids(ids=list(ids)) + return self._execute( + GET_VACANCIES_BY_IDS, + request=VacancyIdsRequest(ids=list(ids)), + timeout=timeout, + retry=retry, + ) @swagger_operation( "POST", @@ -285,15 +544,36 @@ def get_by_ids(self, *, ids: Sequence[int]) -> VacanciesResult: operation_id="vacancyGetStatuses", method_args={"ids": "body.ids"}, ) - def get_statuses(self, *, ids: Sequence[int]) -> VacancyStatusesResult: - """Выполняет публичную операцию `Vacancy.get_statuses` и возвращает типизированную SDK-модель. + def get_statuses( + self, + *, + ids: Sequence[str], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> VacancyStatusesResult: + """Возвращает statuses для вакансий. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Аргументы: + ids: передает идентификаторы объектов для пакетной операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Возвращает: + `VacancyStatusesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return VacanciesClient(self.transport).get_statuses(ids=list(ids)) + return self._execute( + GET_VACANCY_STATUSES, + request=VacancyIdsRequest(ids=list(ids)), + timeout=timeout, + retry=retry, + ) @swagger_operation( "PUT", @@ -308,20 +588,36 @@ def update_auto_renewal( auto_renewal: bool, vacancy_uuid: str | None = None, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> JobActionResult: - """Выполняет публичную операцию `Vacancy.update_auto_renewal` и возвращает типизированную SDK-модель. + """Обновляет настройку автопродления вакансии. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + auto_renewal: включает или отключает автопродление вакансии. + vacancy_uuid: идентифицирует вакансию по UUID. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `JobActionResult` со статусом выполнения операции. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return VacanciesClient(self.transport).update_auto_renewal( - vacancy_uuid=vacancy_uuid or self._require_vacancy_id(), - auto_renewal=auto_renewal, + return self._execute( + UPDATE_VACANCY_AUTO_RENEWAL, + path_params={"vacancy_uuid": vacancy_uuid or self._require_vacancy_id()}, + request=VacancyAutoRenewalRequest(auto_renewal=auto_renewal), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) def _require_vacancy_id(self) -> str: @@ -352,41 +648,37 @@ def apply( ids: Sequence[str], action: str, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> JobActionResult: - """Выполняет публичную операцию `Application.apply` и возвращает типизированную SDK-модель. + """Применяет действие к откликов на вакансии. + + Аргументы: + ids: передает идентификаторы объектов для пакетной операции. + action: задает действие над откликами. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `JobActionResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return ApplicationsClient(self.transport).apply_actions( - ids=list(ids), - action=action, + return self._execute( + APPLY_APPLICATION_ACTIONS, + request=ApplicationActionRequest(ids=list(ids), action=action), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) - def list( - self, - *, - ids: Sequence[str] | None = None, - query: ApplicationIdsQuery | None = None, - ) -> ApplicationsResult | ApplicationIdsResult: - """Выполняет публичную операцию `Application.list` и возвращает типизированную SDK-модель. - - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. - """ - - if ids is not None: - return self.get_by_ids(ids=ids) - if query is None: - raise ValidationError("Для операции требуется `query` или `ids`.") - return self.get_ids(query=query) - @swagger_operation( "POST", "/job/v1/applications/get_by_ids", @@ -394,29 +686,77 @@ def list( operation_id="applicationsGetByIds", method_args={"ids": "body.ids"}, ) - def get_by_ids(self, *, ids: Sequence[str]) -> ApplicationsResult: + def get_by_ids( + self, + *, + ids: Sequence[str], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ApplicationsResult: """Возвращает отклики по идентификаторам и возвращает типизированную SDK-модель. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + ids: передает идентификаторы откликов. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ApplicationsResult` со списком найденных откликов. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return ApplicationsClient(self.transport).get_by_ids(ids=list(ids)) + return self._execute( + GET_APPLICATIONS_BY_IDS, + request=ApplicationIdsRequest(ids=list(ids)), + timeout=timeout, + retry=retry, + ) @swagger_operation( "GET", "/job/v1/applications/get_ids", spec="АвитоРабота.json", operation_id="applicationsGetIds", + method_args={"updated_at_from": "query.updatedAtFrom"}, ) - def get_ids(self, *, query: ApplicationIdsQuery | None = None) -> ApplicationIdsResult: + def get_ids( + self, + *, + updated_at_from: DateInput, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ApplicationIdsResult: """Возвращает идентификаторы откликов по фильтру и возвращает типизированную SDK-модель. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + updated_at_from: фильтрует отклики по нижней границе даты обновления. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ApplicationIdsResult` со списком идентификаторов откликов. + + Поведение: + `updated_at_from` сериализуется в ISO datetime перед выполнением запроса. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - if query is None: - raise ValidationError("Для операции требуется `query`.") - return ApplicationsClient(self.transport).get_ids(query=query) + return self._execute( + GET_APPLICATION_IDS, + query=ApplicationIdsQuery( + updated_at_from=serialize_iso_datetime("updated_at_from", updated_at_from) + ), + timeout=timeout, + retry=retry, + ) @swagger_operation( "GET", @@ -424,15 +764,26 @@ def get_ids(self, *, query: ApplicationIdsQuery | None = None) -> ApplicationIds spec="АвитоРабота.json", operation_id="applicationsGetStates", ) - def get_states(self) -> ApplicationStatesResult: - """Выполняет публичную операцию `Application.get_states` и возвращает типизированную SDK-модель. + def get_states( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> ApplicationStatesResult: + """Возвращает states для откликов на вакансии. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `ApplicationStatesResult` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return ApplicationsClient(self.transport).get_states() + return self._execute(GET_APPLICATION_STATES, timeout=timeout, retry=retry) @swagger_operation( "POST", @@ -446,19 +797,39 @@ def update( *, applies: Sequence[ApplicationViewedItem], idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> JobActionResult: - """Выполняет публичную операцию `Application.update` и возвращает типизированную SDK-модель. + """Обновляет отметки просмотра откликов на вакансии. + + Аргументы: + applies: передает список отметок просмотра откликов. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `JobActionResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return ApplicationsClient(self.transport).set_is_viewed( - applies=list(applies), + return self._execute( + SET_APPLICATIONS_IS_VIEWED, + request=ApplicationViewedRequest( + applies=[ + ApplicationViewedRequestItem(id=item.id, is_viewed=item.is_viewed) + for item in applies + ] + ), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @@ -479,15 +850,36 @@ class Resume(DomainObject): spec="АвитоРабота.json", operation_id="resumesGet", ) - def list(self, *, query: ResumeSearchQuery | None = None) -> ResumesResult: - """Выполняет публичную операцию `Resume.list` и возвращает типизированную SDK-модель. + def list( + self, + *, + query: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ResumesResult: + """Возвращает список резюме. + + Аргументы: + query: передает поисковую строку или фильтр upstream API. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ResumesResult` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return ResumeClient(self.transport).search(query=query) + return self._execute( + SEARCH_RESUMES, + query=ResumeSearchQuery(query=query) if query is not None else None, + timeout=timeout, + retry=retry, + ) @swagger_operation( "GET", @@ -495,16 +887,35 @@ def list(self, *, query: ResumeSearchQuery | None = None) -> ResumesResult: spec="АвитоРабота.json", operation_id="resumeGetItem", ) - def get(self, *, resume_id: int | str | None = None) -> ResumeInfo: - """Выполняет публичную операцию `Resume.get` и возвращает типизированную SDK-модель. + def get( + self, + *, + resume_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ResumeInfo: + """Возвращает резюме. + + Аргументы: + resume_id: идентифицирует резюме. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ResumeInfo` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return ResumeClient(self.transport).get_item( - resume_id=str(resume_id or self._require_resume_id()) + return self._execute( + GET_RESUME, + path_params={"resume_id": str(resume_id or self._require_resume_id())}, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -513,16 +924,35 @@ def get(self, *, resume_id: int | str | None = None) -> ResumeInfo: spec="АвитоРабота.json", operation_id="resumeGetContacts", ) - def get_contacts(self, *, resume_id: int | str | None = None) -> ResumeContactInfo: - """Выполняет публичную операцию `Resume.get_contacts` и возвращает типизированную SDK-модель. + def get_contacts( + self, + *, + resume_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ResumeContactInfo: + """Возвращает contacts для резюме. + + Аргументы: + resume_id: идентифицирует резюме. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `ResumeContactInfo` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return ResumeClient(self.transport).get_contacts( - resume_id=str(resume_id or self._require_resume_id()) + return self._execute( + GET_RESUME_CONTACTS, + path_params={"resume_id": str(resume_id or self._require_resume_id())}, + timeout=timeout, + retry=retry, ) def _require_resume_id(self) -> str: @@ -546,15 +976,26 @@ class JobWebhook(DomainObject): spec="АвитоРабота.json", operation_id="applicationsWebhookGet", ) - def get(self) -> JobWebhookInfo: - """Выполняет публичную операцию `JobWebhook.get` и возвращает типизированную SDK-модель. + def get( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> JobWebhookInfo: + """Возвращает webhook-уведомлений Авито Работы. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobWebhookInfo` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return WebhookClient(self.transport).get_webhook() + return self._execute(GET_JOB_WEBHOOK, timeout=timeout, retry=retry) @swagger_operation( "GET", @@ -562,36 +1003,69 @@ def get(self) -> JobWebhookInfo: spec="АвитоРабота.json", operation_id="applicationsWebhooksGet", ) - def list(self) -> JobWebhooksResult: - """Выполняет публичную операцию `JobWebhook.list` и возвращает типизированную SDK-модель. + def list( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> JobWebhooksResult: + """Возвращает список webhook-уведомлений Авито Работы. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobWebhooksResult` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return WebhookClient(self.transport).list_webhooks() + return self._execute(LIST_JOB_WEBHOOKS, timeout=timeout, retry=retry) @swagger_operation( "PUT", "/job/v1/applications/webhook", spec="АвитоРабота.json", operation_id="applicationsWebhookPut", - method_args={"url": "body.url"}, + method_args={"url": "body.url", "secret": "body.secret"}, ) - def update(self, *, url: str, idempotency_key: str | None = None) -> JobWebhookInfo: - """Выполняет публичную операцию `JobWebhook.update` и возвращает типизированную SDK-модель. - - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. - - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + def update( + self, + *, + url: str, + secret: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobWebhookInfo: + """Обновляет webhook-уведомление Авито Работы. + + Аргументы: + url: задает URL webhook-подписки. + secret: задает секрет webhook-подписки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobWebhookInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return WebhookClient(self.transport).put_webhook( - url=url, + return self._execute( + UPDATE_JOB_WEBHOOK, + request=JobWebhookUpdateRequest(url=url, secret=secret), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -601,20 +1075,38 @@ def update(self, *, url: str, idempotency_key: str | None = None) -> JobWebhookI operation_id="applicationsWebhookDelete", ) def delete( - self, *, url: str | None = None, idempotency_key: str | None = None + self, + *, + url: str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> JobActionResult: - """Выполняет публичную операцию `JobWebhook.delete` и возвращает типизированную SDK-модель. + """Удаляет webhook-уведомление Авито Работы. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + url: задает URL webhook-подписки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `JobActionResult` со статусом выполнения операции. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return WebhookClient(self.transport).delete_webhook( - url=url, + return self._execute( + DELETE_JOB_WEBHOOK, + query={"url": url} if url is not None else None, idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @@ -635,15 +1127,26 @@ class JobDictionary(DomainObject): spec="АвитоРабота.json", operation_id="getDicts", ) - def list(self) -> JobDictionariesResult: - """Выполняет публичную операцию `JobDictionary.list` и возвращает типизированную SDK-модель. + def list( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> JobDictionariesResult: + """Возвращает список справочников Авито Работы. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `JobDictionariesResult` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return DictionariesClient(self.transport).list_dicts() + return self._execute(LIST_JOB_DICTIONARIES, timeout=timeout, retry=retry) @swagger_operation( "GET", @@ -651,16 +1154,35 @@ def list(self) -> JobDictionariesResult: spec="АвитоРабота.json", operation_id="getDictByID", ) - def get(self, *, dictionary_id: str | None = None) -> JobDictionaryValuesResult: - """Выполняет публичную операцию `JobDictionary.get` и возвращает типизированную SDK-модель. + def get( + self, + *, + dictionary_id: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobDictionaryValuesResult: + """Возвращает справочников Авито Работы. + + Аргументы: + dictionary_id: идентифицирует справочник. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobDictionaryValuesResult` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return DictionariesClient(self.transport).get_dict_by_id( - dictionary_id=dictionary_id or self._require_dictionary_id() + return self._execute( + GET_JOB_DICTIONARY, + path_params={"dictionary_id": dictionary_id or self._require_dictionary_id()}, + timeout=timeout, + retry=retry, ) def _require_dictionary_id(self) -> str: diff --git a/avito/jobs/enums.py b/avito/jobs/enums.py deleted file mode 100644 index a88a599..0000000 --- a/avito/jobs/enums.py +++ /dev/null @@ -1,81 +0,0 @@ -"""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" - ACTIVATED = "activated" - ARCHIVED = "archived" - BLOCKED = "blocked" - CLOSED = "closed" - EXPIRED = "expired" - REJECTED = "rejected" - UNBLOCKED = "unblocked" - - -class VacancyModerationStatus(str, Enum): - """Статус модерации вакансии.""" - - UNKNOWN = "__unknown__" - IN_PROGRESS = "in_progress" - ALLOWED = "allowed" - BLOCKED = "blocked" - REJECTED = "rejected" - - -class JobEnrichmentStatus(str, Enum): - """Статус обогащения параметров вакансии.""" - - UNKNOWN = "__unknown__" - IN_PROGRESS = "in_progress" - NOT_COMPLETED = "not_completed" - COMPLETED_NO_CRITERIA = "completed_no_criteria" - COMPLETED_MATCHED = "completed_matched" - COMPLETED_MISMATCHED = "completed_mismatched" - - -class JobMatchingStatus(str, Enum): - """Статус сопоставления критерия вакансии.""" - - UNKNOWN = "__unknown__" - NO_CRITERIA = "no_criteria" - MATCHED = "matched" - MISMATCHED = "mismatched" - - -__all__ = ( - "ApplicationStatus", - "JobActionStatus", - "JobEnrichmentStatus", - "JobMatchingStatus", - "VacancyModerationStatus", - "VacancyStatus", -) diff --git a/avito/jobs/mappers.py b/avito/jobs/mappers.py deleted file mode 100644 index 13130ab..0000000 --- a/avito/jobs/mappers.py +++ /dev/null @@ -1,346 +0,0 @@ -"""Мапперы JSON -> dataclass для пакета jobs.""" - -from __future__ import annotations - -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, - VacancyModerationStatus, - VacancyStatus, -) -from avito.jobs.models import ( - ApplicationIdItem, - ApplicationIdsResult, - ApplicationInfo, - ApplicationsResult, - ApplicationState, - ApplicationStatesResult, - JobActionResult, - JobDictionariesResult, - JobDictionaryInfo, - JobDictionaryValue, - JobDictionaryValuesResult, - JobWebhookInfo, - JobWebhooksResult, - ResumeContactInfo, - ResumeInfo, - ResumesResult, - VacanciesResult, - VacancyInfo, - VacancyStatusesResult, - VacancyStatusInfo, -) - -Payload = Mapping[str, object] - - -def _expect_mapping(payload: object) -> Payload: - if not isinstance(payload, Mapping): - raise ResponseMappingError("Ожидался JSON-объект.", payload=payload) - return cast(Payload, payload) - - -def _expect_list(payload: object) -> list[Payload]: - if not isinstance(payload, list): - raise ResponseMappingError("Ожидался JSON-массив.", payload=payload) - return [item for item in payload if isinstance(item, Mapping)] - - -def _list(payload: Payload, *keys: str) -> list[Payload]: - for key in keys: - value = payload.get(key) - if isinstance(value, list): - return [item for item in value if isinstance(item, Mapping)] - return [] - - -def _mapping(payload: Payload, *keys: str) -> Payload: - for key in keys: - value = payload.get(key) - if isinstance(value, Mapping): - return cast(Payload, value) - return {} - - -def _str(payload: Payload, *keys: str) -> str | None: - for key in keys: - value = payload.get(key) - if isinstance(value, str): - return value - return None - - -def _int(payload: Payload, *keys: str) -> int | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - continue - if isinstance(value, int): - return value - return None - - -def _bool(payload: Payload, *keys: str) -> bool | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - return value - return None - - -def map_job_action(payload: object) -> JobActionResult: - """Преобразует результат mutation-операции Jobs API.""" - - data = _expect_mapping(payload) - result = _mapping(data, "result") - source = result or data - identifier = _str(source, "id", "uuid", "vacancy_uuid", "vacancyUuid", "apply_id") - numeric_id = _int(source, "id", "vacancy_id", "vacancyId") - 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=map_enum_or_unknown( - _str(source, "status", "state"), - JobActionStatus, - enum_name="jobs.action_status", - ), - message=_str(source, "message"), - ) - - -def map_application(payload: Payload) -> ApplicationInfo: - return ApplicationInfo( - id=_str(payload, "id"), - vacancy_id=_int(payload, "vacancy_id", "vacancyId"), - resume_id=_str(payload, "resume_id", "resumeId"), - 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"), - ) - - -def map_applications(payload: object) -> ApplicationsResult: - """Преобразует список откликов.""" - - data = _expect_mapping(payload) - return ApplicationsResult( - items=[ - map_application(item) - for item in _list(data, "applies", "applications", "items", "result") - ], - ) - - -def map_application_ids(payload: object) -> ApplicationIdsResult: - """Преобразует список идентификаторов откликов.""" - - data = _expect_mapping(payload) - return ApplicationIdsResult( - items=[ - ApplicationIdItem( - id=_str(item, "id"), - updated_at=_str(item, "updatedAt", "updated_at"), - ) - for item in _list(data, "items", "applies", "result") - ], - cursor=_str(_mapping(data, "meta"), "cursor") or _str(data, "cursor"), - ) - - -def map_application_states(payload: object) -> ApplicationStatesResult: - """Преобразует список статусов откликов.""" - - data = _expect_mapping(payload) - return ApplicationStatesResult( - items=[ - ApplicationState( - slug=_str(item, "slug", "id"), - description=_str(item, "description", "name"), - ) - for item in _list(data, "states", "items", "result") - ], - ) - - -def map_resume(payload: Payload) -> ResumeInfo: - salary_payload = _mapping(payload, "salary") - return ResumeInfo( - id=_str(payload, "id", "resume_id", "resumeId"), - title=_str(payload, "title"), - candidate_name=_str(payload, "name", "full_name", "fullName"), - location=_str(payload, "location") - or _str(_mapping(payload, "address_details"), "location"), - salary=_int(payload, "salary") or _int(salary_payload, "value", "from"), - ) - - -def map_resumes(payload: object) -> ResumesResult: - """Преобразует поиск резюме.""" - - data = _expect_mapping(payload) - meta = _mapping(data, "meta") - return ResumesResult( - items=[map_resume(item) for item in _list(data, "resumes", "items", "result")], - cursor=_str(meta, "cursor"), - total=_int(meta, "total"), - ) - - -def map_resume_item(payload: object) -> ResumeInfo: - """Преобразует резюме в полную модель.""" - - data = _expect_mapping(payload) - return map_resume(data) - - -def map_resume_contacts(payload: object) -> ResumeContactInfo: - """Преобразует контакты резюме.""" - - data = _expect_mapping(payload) - return ResumeContactInfo( - name=_str(data, "name", "fullName"), - phone=_str(data, "phone", "phoneNumber"), - email=_str(data, "email"), - ) - - -def map_vacancy(payload: Payload) -> VacancyInfo: - return VacancyInfo( - id=_str(payload, "id", "vacancy_id", "vacancyId") - or ( - str(_int(payload, "id", "vacancy_id", "vacancyId")) - if _int(payload, "id", "vacancy_id", "vacancyId") is not None - else None - ), - uuid=_str(payload, "uuid", "vacancy_uuid", "vacancyUuid"), - title=_str(payload, "title", "name"), - status=map_enum_or_unknown( - _str(payload, "status", "state"), - VacancyStatus, - enum_name="jobs.vacancy_status", - ), - url=_str(payload, "url"), - ) - - -def map_vacancy_item(payload: object) -> VacancyInfo: - """Преобразует одну вакансию.""" - - data = _expect_mapping(payload) - return map_vacancy(data) - - -def map_vacancies(payload: object) -> VacanciesResult: - """Преобразует список вакансий.""" - - data = _expect_mapping(payload) - items = _list(data, "vacancies", "items", "result") - if not items and isinstance(payload, list): - items = _expect_list(payload) - meta = _mapping(data, "meta") - return VacanciesResult( - items=[map_vacancy(item) for item in items], - total=_int(meta, "total") or _int(data, "total"), - ) - - -def map_vacancy_statuses(payload: object) -> VacancyStatusesResult: - """Преобразует статусы вакансий.""" - - data = _expect_mapping(payload) - return VacancyStatusesResult( - items=[ - VacancyStatusInfo( - id=_str(_mapping(item, "vacancy") or item, "id", "vacancy_id") - or ( - str(_int(_mapping(item, "vacancy") or item, "id", "vacancy_id")) - if _int(_mapping(item, "vacancy") or item, "id", "vacancy_id") is not None - else None - ), - uuid=_str(_mapping(item, "vacancy") or item, "uuid", "vacancy_uuid"), - status=map_enum_or_unknown( - _str(_mapping(item, "vacancy") or item, "status", "state"), - VacancyStatus, - enum_name="jobs.vacancy_status", - ), - moderation_status=map_enum_or_unknown( - _str(_mapping(item, "vacancy") or item, "moderation_status", "moderationStatus"), - VacancyModerationStatus, - enum_name="jobs.vacancy_moderation_status", - ), - ) - for item in _list(data, "items", "statuses", "vacancies", "result") - ], - ) - - -def map_job_webhook(payload: object) -> JobWebhookInfo: - """Преобразует одну webhook-подписку.""" - - data = _expect_mapping(payload) - return JobWebhookInfo( - url=_str(data, "url"), - is_active=_bool(data, "is_active", "isActive", "active"), - version=_str(data, "version"), - ) - - -def map_job_webhooks(payload: object) -> JobWebhooksResult: - """Преобразует список webhook-подписок.""" - - if isinstance(payload, list): - items_payload = _expect_list(payload) - return JobWebhooksResult(items=[map_job_webhook(item) for item in items_payload]) - - data = _expect_mapping(payload) - return JobWebhooksResult( - items=[map_job_webhook(item) for item in _list(data, "items", "webhooks", "result")], - ) - - -def map_job_dictionaries(payload: object) -> JobDictionariesResult: - """Преобразует список доступных словарей.""" - - items_payload = ( - _expect_list(payload) - if isinstance(payload, list) - else _list(_expect_mapping(payload), "items", "result") - ) - return JobDictionariesResult( - items=[ - JobDictionaryInfo( - id=_str(item, "id"), - description=_str(item, "description"), - ) - for item in items_payload - ], - ) - - -def map_job_dictionary_values(payload: object) -> JobDictionaryValuesResult: - """Преобразует значения словаря вакансий.""" - - items_payload = ( - _expect_list(payload) - if isinstance(payload, list) - else _list(_expect_mapping(payload), "items", "result") - ) - return JobDictionaryValuesResult( - items=[ - JobDictionaryValue( - id=_int(item, "id") if _int(item, "id") is not None else _str(item, "id"), - name=_str(item, "name", "description"), - deprecated=_bool(item, "deprecated"), - ) - for item in items_payload - ], - ) diff --git a/avito/jobs/models.py b/avito/jobs/models.py index 6d04bc3..2c4c504 100644 --- a/avito/jobs/models.py +++ b/avito/jobs/models.py @@ -2,31 +2,138 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass +from enum import Enum -from avito.core.serialization import SerializableModel -from avito.jobs.enums import ( - ApplicationStatus, - JobActionStatus, - VacancyModerationStatus, - VacancyStatus, -) +from avito.core import ApiModel, RequestModel +from avito.core.exceptions import ResponseMappingError, ValidationError + +Payload = Mapping[str, object] + + +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" + ACTIVATED = "activated" + ARCHIVED = "archived" + BLOCKED = "blocked" + CLOSED = "closed" + EXPIRED = "expired" + REJECTED = "rejected" + UNBLOCKED = "unblocked" + + +class VacancyModerationStatus(str, Enum): + """Статус модерации вакансии.""" + + UNKNOWN = "__unknown__" + IN_PROGRESS = "in_progress" + ALLOWED = "allowed" + BLOCKED = "blocked" + REJECTED = "rejected" + + +class JobEnrichmentStatus(str, Enum): + """Статус обогащения параметров вакансии.""" + + UNKNOWN = "__unknown__" + IN_PROGRESS = "in_progress" + NOT_COMPLETED = "not_completed" + COMPLETED_NO_CRITERIA = "completed_no_criteria" + COMPLETED_MATCHED = "completed_matched" + COMPLETED_MISMATCHED = "completed_mismatched" + + +class JobMatchingStatus(str, Enum): + """Статус сопоставления критерия вакансии.""" + + UNKNOWN = "__unknown__" + NO_CRITERIA = "no_criteria" + MATCHED = "matched" + MISMATCHED = "mismatched" + + +class VacancyBillingType(str, Enum): + """Вариант платного размещения вакансии.""" + + PACKAGE = "package" + SINGLE = "single" + PACKAGE_OR_SINGLE = "packageOrSingle" + + +class VacancyEmployment(str, Enum): + """Тип занятости вакансии.""" + + TEMPORARY = "temporary" + FULL = "full" + INTERNSHIP = "internship" + PARTIAL = "partial" + + +class VacancySchedule(str, Enum): + """Режим работы вакансии.""" + + FLY_IN_FLY_OUT = "flyInFlyOut" + FIXED = "fixed" + FLEXIBLE = "flexible" + SHIFT = "shift" + + +class VacancyExperience(str, Enum): + """Требуемый опыт работы для вакансии.""" + + NO_MATTER = "noMatter" + MORE_THAN_1 = "moreThan1" + MORE_THAN_3 = "moreThan3" + MORE_THAN_5 = "moreThan5" + MORE_THAN_10 = "moreThan10" + + +VacancyBillingTypeInput = VacancyBillingType | str +VacancyEmploymentInput = VacancyEmployment | str +VacancyScheduleInput = VacancySchedule | str +VacancyExperienceInput = VacancyExperience | str @dataclass(slots=True, frozen=True) -class ApplicationIdsQuery: +class ApplicationIdsQuery(RequestModel): """Query списка идентификаторов откликов.""" updated_at_from: str - def to_params(self) -> dict[str, str]: + def to_params(self) -> dict[str, object]: """Сериализует query-параметры идентификаторов откликов.""" return {"updatedAtFrom": self.updated_at_from} @dataclass(slots=True, frozen=True) -class ApplicationIdsRequest: +class ApplicationIdsRequest(RequestModel): """Запрос получения откликов по идентификаторам.""" ids: list[str] @@ -38,7 +145,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class ApplicationActionRequest: +class ApplicationActionRequest(RequestModel): """Запрос действия над откликами.""" ids: list[str] @@ -51,7 +158,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class ApplicationViewedItem: +class ApplicationViewedItem(ApiModel): """Флаг просмотра для отклика.""" id: str @@ -62,12 +169,32 @@ def to_payload(self) -> dict[str, object]: return {"id": self.id, "is_viewed": self.is_viewed} + @classmethod + def from_payload(cls, payload: object) -> ApplicationViewedItem: + """Преобразует флаг просмотра отклика.""" + + data = _expect_mapping(payload) + return cls(id=str(data.get("id", "")), is_viewed=bool(data.get("is_viewed"))) + + +@dataclass(slots=True, frozen=True) +class ApplicationViewedRequestItem(RequestModel): + """Внутренний элемент запроса обновления флага просмотра.""" + + id: str + is_viewed: bool + + def to_payload(self) -> dict[str, object]: + """Сериализует флаг просмотра отклика.""" + + return {"id": self.id, "is_viewed": self.is_viewed} + @dataclass(slots=True, frozen=True) -class ApplicationViewedRequest: +class ApplicationViewedRequest(RequestModel): """Запрос обновления флага просмотра откликов.""" - applies: list[ApplicationViewedItem] + applies: list[ApplicationViewedRequestItem] def to_payload(self) -> dict[str, object]: """Сериализует запрос обновления просмотра откликов.""" @@ -76,70 +203,121 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class JobWebhookUpdateRequest: +class JobWebhookUpdateRequest(RequestModel): """Запрос обновления webhook откликов.""" url: str + secret: str def to_payload(self) -> dict[str, object]: """Сериализует webhook откликов.""" - return {"url": self.url} + return {"url": self.url, "secret": self.secret} @dataclass(slots=True, frozen=True) -class ResumeSearchQuery: +class ResumeSearchQuery(RequestModel): """Query поиска резюме.""" query: str - def to_params(self) -> dict[str, str]: + def to_params(self) -> dict[str, object]: """Сериализует query поиска резюме.""" return {"query": self.query} @dataclass(slots=True, frozen=True) -class VacanciesQuery: +class VacanciesQuery(RequestModel): """Query списка или карточки вакансий.""" query: str | None = None - def to_params(self) -> dict[str, str]: + def to_params(self) -> dict[str, object]: """Сериализует query вакансий.""" - params: dict[str, str] = {} + params: dict[str, object] = {} if self.query is not None: params["query"] = self.query return params @dataclass(slots=True, frozen=True) -class VacancyCreateRequest: - """Запрос создания вакансии.""" +class VacancyCreateRequest(RequestModel): + """Запрос создания вакансии v2.""" title: str + billing_type: VacancyBillingTypeInput def to_payload(self) -> dict[str, object]: """Сериализует создание вакансии.""" - return {"title": self.title} + return { + "title": self.title, + "billing_type": _enum_value(VacancyBillingType, "billing_type", self.billing_type), + } + + +@dataclass(slots=True, frozen=True) +class VacancyClassicCreateRequest(RequestModel): + """Запрос создания вакансии v1.""" + + title: str + description: str + billing_type: VacancyBillingTypeInput + business_area: int + employment: VacancyEmploymentInput + schedule: VacancyScheduleInput + experience: VacancyExperienceInput + + def to_payload(self) -> dict[str, object]: + """Сериализует создание вакансии v1.""" + + return { + "name": self.title, + "description": self.description, + "billing_type": _enum_value(VacancyBillingType, "billing_type", self.billing_type), + "business_area": self.business_area, + "employment": _enum_value(VacancyEmployment, "employment", self.employment), + "schedule": {"id": _enum_value(VacancySchedule, "schedule", self.schedule)}, + "experience": {"id": _enum_value(VacancyExperience, "experience", self.experience)}, + } @dataclass(slots=True, frozen=True) -class VacancyUpdateRequest: +class VacancyUpdateRequest(RequestModel): """Запрос обновления вакансии.""" title: str + billing_type: VacancyBillingTypeInput def to_payload(self) -> dict[str, object]: """Сериализует обновление вакансии.""" - return {"title": self.title} + return { + "title": self.title, + "billing_type": _enum_value(VacancyBillingType, "billing_type", self.billing_type), + } + + +@dataclass(slots=True, frozen=True) +class VacancyClassicUpdateRequest(RequestModel): + """Запрос обновления вакансии v1.""" + + title: str + billing_type: VacancyBillingTypeInput + + def to_payload(self) -> dict[str, object]: + """Сериализует обновление вакансии v1.""" + + return { + "name": self.title, + "billing_type": _enum_value(VacancyBillingType, "billing_type", self.billing_type), + } @dataclass(slots=True, frozen=True) -class VacancyArchiveRequest: +class VacancyArchiveRequest(RequestModel): """Запрос архивации вакансии v1.""" employee_id: int @@ -151,22 +329,22 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class VacancyProlongateRequest: +class VacancyProlongateRequest(RequestModel): """Запрос продления вакансии v1.""" - billing_type: str + billing_type: VacancyBillingTypeInput def to_payload(self) -> dict[str, object]: """Сериализует продление вакансии.""" - return {"billing_type": self.billing_type} + return {"billing_type": _enum_value(VacancyBillingType, "billing_type", self.billing_type)} @dataclass(slots=True, frozen=True) -class VacancyIdsRequest: +class VacancyIdsRequest(RequestModel): """Запрос списка вакансий по идентификаторам.""" - ids: list[int] + ids: list[int | str] def to_payload(self) -> dict[str, object]: """Сериализует идентификаторы вакансий.""" @@ -175,7 +353,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class VacancyAutoRenewalRequest: +class VacancyAutoRenewalRequest(RequestModel): """Запрос обновления автообновления вакансии.""" auto_renewal: bool @@ -187,7 +365,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class JobActionResult(SerializableModel): +class JobActionResult(ApiModel): """Результат mutation-операции Jobs API.""" success: bool @@ -195,9 +373,25 @@ class JobActionResult(SerializableModel): status: JobActionStatus | None = None message: str | None = None + @classmethod + def from_payload(cls, payload: object) -> JobActionResult: + """Преобразует результат mutation-операции Jobs API.""" + + data = _expect_mapping(payload) + result = _mapping(data, "result") + source = result or data + identifier = _str(source, "id", "uuid", "vacancy_uuid", "vacancyUuid", "apply_id") + numeric_id = _int(source, "id", "vacancy_id", "vacancyId") + return cls( + success=bool(source.get("ok", source.get("success", True))), + id=identifier or (str(numeric_id) if numeric_id is not None else None), + status=_enum(JobActionStatus, _str(source, "status", "state")), + message=_str(source, "message"), + ) + @dataclass(slots=True, frozen=True) -class ApplicationInfo(SerializableModel): +class ApplicationInfo(ApiModel): """Информация об отклике.""" id: str | None @@ -207,16 +401,38 @@ class ApplicationInfo(SerializableModel): is_viewed: bool | None applicant_name: str | None + @classmethod + def from_payload(cls, payload: object) -> ApplicationInfo: + data = _expect_mapping(payload) + return cls( + id=_str(data, "id"), + vacancy_id=_int(data, "vacancy_id", "vacancyId"), + resume_id=_str(data, "resume_id", "resumeId"), + state=_enum(ApplicationStatus, _str(data, "state", "status")), + is_viewed=_bool(data, "is_viewed", "isViewed"), + applicant_name=_str(_mapping(data, "applicant"), "name", "fullName"), + ) + @dataclass(slots=True, frozen=True) -class ApplicationsResult(SerializableModel): +class ApplicationsResult(ApiModel): """Список откликов.""" items: list[ApplicationInfo] + @classmethod + def from_payload(cls, payload: object) -> ApplicationsResult: + data = _expect_mapping(payload) + return cls( + items=[ + ApplicationInfo.from_payload(item) + for item in _list(data, "applies", "applications", "items", "result") + ], + ) + @dataclass(slots=True, frozen=True) -class ApplicationIdItem(SerializableModel): +class ApplicationIdItem(ApiModel): """Идентификатор отклика.""" id: str | None @@ -224,15 +440,28 @@ class ApplicationIdItem(SerializableModel): @dataclass(slots=True, frozen=True) -class ApplicationIdsResult(SerializableModel): +class ApplicationIdsResult(ApiModel): """Постраничный список идентификаторов откликов.""" items: list[ApplicationIdItem] cursor: str | None = None + @classmethod + def from_payload(cls, payload: object) -> ApplicationIdsResult: + data = _expect_mapping(payload) + return cls( + items=[ + ApplicationIdItem( + id=_str(item, "id"), updated_at=_str(item, "updatedAt", "updated_at") + ) + for item in _list(data, "items", "applies", "result") + ], + cursor=_str(_mapping(data, "meta"), "cursor") or _str(data, "cursor"), + ) + @dataclass(slots=True, frozen=True) -class ApplicationState(SerializableModel): +class ApplicationState(ApiModel): """Статус отклика.""" slug: str | None @@ -240,14 +469,27 @@ class ApplicationState(SerializableModel): @dataclass(slots=True, frozen=True) -class ApplicationStatesResult(SerializableModel): +class ApplicationStatesResult(ApiModel): """Список возможных статусов откликов.""" items: list[ApplicationState] + @classmethod + def from_payload(cls, payload: object) -> ApplicationStatesResult: + data = _expect_mapping(payload) + return cls( + items=[ + ApplicationState( + slug=_str(item, "slug", "id"), + description=_str(item, "description", "name"), + ) + for item in _list(data, "states", "items", "result") + ], + ) + @dataclass(slots=True, frozen=True) -class ResumeInfo(SerializableModel): +class ResumeInfo(ApiModel): """Краткая или полная информация о резюме.""" id: str | None @@ -256,27 +498,60 @@ class ResumeInfo(SerializableModel): location: str | None salary: int | None + @classmethod + def from_payload(cls, payload: object) -> ResumeInfo: + data = _expect_mapping(payload) + salary_payload = _mapping(data, "salary") + return cls( + id=_str(data, "id", "resume_id", "resumeId"), + title=_str(data, "title"), + candidate_name=_str(data, "name", "full_name", "fullName"), + location=_str(data, "location") or _str(_mapping(data, "address_details"), "location"), + salary=_int(data, "salary") or _int(salary_payload, "value", "from"), + ) + @dataclass(slots=True, frozen=True) -class ResumesResult(SerializableModel): +class ResumesResult(ApiModel): """Результат поиска резюме.""" items: list[ResumeInfo] cursor: str | None = None total: int | None = None + @classmethod + def from_payload(cls, payload: object) -> ResumesResult: + data = _expect_mapping(payload) + meta = _mapping(data, "meta") + return cls( + items=[ + ResumeInfo.from_payload(item) for item in _list(data, "resumes", "items", "result") + ], + cursor=_str(meta, "cursor"), + total=_int(meta, "total"), + ) + @dataclass(slots=True, frozen=True) -class ResumeContactInfo(SerializableModel): +class ResumeContactInfo(ApiModel): """Контакты соискателя.""" name: str | None phone: str | None email: str | None + @classmethod + def from_payload(cls, payload: object) -> ResumeContactInfo: + data = _expect_mapping(payload) + return cls( + name=_str(data, "name", "fullName"), + phone=_str(data, "phone", "phoneNumber"), + email=_str(data, "email"), + ) + @dataclass(slots=True, frozen=True) -class VacancyInfo(SerializableModel): +class VacancyInfo(ApiModel): """Информация о вакансии.""" id: str | None @@ -285,17 +560,42 @@ class VacancyInfo(SerializableModel): status: VacancyStatus | None url: str | None + @classmethod + def from_payload(cls, payload: object) -> VacancyInfo: + data = _expect_mapping(payload) + numeric_id = _int(data, "id", "vacancy_id", "vacancyId") + return cls( + id=_str(data, "id", "vacancy_id", "vacancyId") + or (str(numeric_id) if numeric_id is not None else None), + uuid=_str(data, "uuid", "vacancy_uuid", "vacancyUuid"), + title=_str(data, "title", "name"), + status=_enum(VacancyStatus, _str(data, "status", "state")), + url=_str(data, "url"), + ) + @dataclass(slots=True, frozen=True) -class VacanciesResult(SerializableModel): +class VacanciesResult(ApiModel): """Список вакансий.""" items: list[VacancyInfo] total: int | None = None + @classmethod + def from_payload(cls, payload: object) -> VacanciesResult: + items = _expect_list(payload) if isinstance(payload, list) else [] + data = {} if isinstance(payload, list) else _expect_mapping(payload) + if not items: + items = _list(data, "vacancies", "items", "result") + meta = _mapping(data, "meta") + return cls( + items=[VacancyInfo.from_payload(item) for item in items], + total=_int(meta, "total") or _int(data, "total"), + ) + @dataclass(slots=True, frozen=True) -class VacancyStatusInfo(SerializableModel): +class VacancyStatusInfo(ApiModel): """Статус публикации вакансии v2.""" id: str | None @@ -305,30 +605,76 @@ class VacancyStatusInfo(SerializableModel): @dataclass(slots=True, frozen=True) -class VacancyStatusesResult(SerializableModel): +class VacancyStatusesResult(ApiModel): """Список статусов вакансий.""" items: list[VacancyStatusInfo] - -@dataclass(slots=True, frozen=True) -class JobWebhookInfo(SerializableModel): + @classmethod + def from_payload(cls, payload: object) -> VacancyStatusesResult: + if isinstance(payload, list): + raw_items = _expect_list(payload) + else: + data = _expect_mapping(payload) + raw_items = _list(data, "items", "statuses", "vacancies", "result") + items: list[VacancyStatusInfo] = [] + for item in raw_items: + vacancy = _mapping(item, "vacancy") or item + numeric_id = _int(vacancy, "id", "vacancy_id") + items.append( + VacancyStatusInfo( + id=_str(vacancy, "id", "vacancy_id") + or (str(numeric_id) if numeric_id is not None else None), + uuid=_str(vacancy, "uuid", "vacancy_uuid"), + status=_enum(VacancyStatus, _str(vacancy, "status", "state")), + moderation_status=_enum( + VacancyModerationStatus, + _str(vacancy, "moderation_status", "moderationStatus"), + ), + ) + ) + return cls(items=items) + + +@dataclass(slots=True, frozen=True) +class JobWebhookInfo(ApiModel): """Подписка webhook раздела Работа.""" url: str | None is_active: bool | None version: str | None + @classmethod + def from_payload(cls, payload: object) -> JobWebhookInfo: + data = _expect_mapping(payload) + return cls( + url=_str(data, "url"), + is_active=_bool(data, "is_active", "isActive", "active"), + version=_str(data, "version"), + ) + @dataclass(slots=True, frozen=True) -class JobWebhooksResult(SerializableModel): +class JobWebhooksResult(ApiModel): """Список webhook-подписок.""" items: list[JobWebhookInfo] + @classmethod + def from_payload(cls, payload: object) -> JobWebhooksResult: + if isinstance(payload, list): + return cls(items=[JobWebhookInfo.from_payload(item) for item in _expect_list(payload)]) + data = _expect_mapping(payload) + return cls( + items=[ + JobWebhookInfo.from_payload(item) + for item in _list(data, "items", "webhooks", "result") + ], + ) + @dataclass(slots=True, frozen=True) -class JobDictionaryInfo(SerializableModel): +class JobDictionaryInfo(ApiModel): """Справочник вакансий.""" id: str | None @@ -336,14 +682,28 @@ class JobDictionaryInfo(SerializableModel): @dataclass(slots=True, frozen=True) -class JobDictionariesResult(SerializableModel): +class JobDictionariesResult(ApiModel): """Список доступных словарей.""" items: list[JobDictionaryInfo] + @classmethod + def from_payload(cls, payload: object) -> JobDictionariesResult: + items_payload = ( + _expect_list(payload) + if isinstance(payload, list) + else _list(_expect_mapping(payload), "items", "result") + ) + return cls( + items=[ + JobDictionaryInfo(id=_str(item, "id"), description=_str(item, "description")) + for item in items_payload + ], + ) + @dataclass(slots=True, frozen=True) -class JobDictionaryValue(SerializableModel): +class JobDictionaryValue(ApiModel): """Значение словаря вакансий.""" id: int | str | None @@ -352,7 +712,102 @@ class JobDictionaryValue(SerializableModel): @dataclass(slots=True, frozen=True) -class JobDictionaryValuesResult(SerializableModel): +class JobDictionaryValuesResult(ApiModel): """Список значений словаря.""" items: list[JobDictionaryValue] + + @classmethod + def from_payload(cls, payload: object) -> JobDictionaryValuesResult: + items_payload = ( + _expect_list(payload) + if isinstance(payload, list) + else _list(_expect_mapping(payload), "items", "result") + ) + return cls( + items=[ + JobDictionaryValue( + id=_int(item, "id") if _int(item, "id") is not None else _str(item, "id"), + name=_str(item, "name", "description"), + deprecated=_bool(item, "deprecated"), + ) + for item in items_payload + ], + ) + + +def _expect_mapping(payload: object) -> Payload: + if not isinstance(payload, Mapping): + raise ResponseMappingError("Ожидался JSON-объект.", payload=payload) + return payload + + +def _expect_list(payload: object) -> list[Payload]: + if not isinstance(payload, list): + raise ResponseMappingError("Ожидался JSON-массив.", payload=payload) + return [item for item in payload if isinstance(item, Mapping)] + + +def _list(payload: Payload, *keys: str) -> list[Payload]: + for key in keys: + value = payload.get(key) + if isinstance(value, list): + return [item for item in value if isinstance(item, Mapping)] + return [] + + +def _mapping(payload: Payload, *keys: str) -> Payload: + for key in keys: + value = payload.get(key) + if isinstance(value, Mapping): + return value + return {} + + +def _str(payload: Payload, *keys: str) -> str | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str): + return value + return None + + +def _int(payload: Payload, *keys: str) -> int | None: + for key in keys: + value = payload.get(key) + if isinstance(value, bool): + continue + if isinstance(value, int): + return value + return None + + +def _bool(payload: Payload, *keys: str) -> bool | None: + for key in keys: + value = payload.get(key) + if isinstance(value, bool): + return value + return None + + +def _enum[EnumT: Enum](enum_type: type[EnumT], value: str | None) -> EnumT | None: + if value is None: + return None + try: + return enum_type(value) + except ValueError: + return enum_type("__unknown__") + + +def _enum_value[EnumT: Enum]( + enum_type: type[EnumT], + name: str, + value: EnumT | str, +) -> str: + if isinstance(value, enum_type): + return str(value.value) + try: + return str(enum_type(value).value) + except ValueError as exc: + allowed = ", ".join(str(item.value) for item in enum_type) + raise ValidationError(f"`{name}` должен быть одним из: {allowed}.") from exc diff --git a/avito/jobs/operations.py b/avito/jobs/operations.py new file mode 100644 index 0000000..4a2147d --- /dev/null +++ b/avito/jobs/operations.py @@ -0,0 +1,246 @@ +"""Operation specs for jobs domain.""" + +from __future__ import annotations + +from avito.core import OperationSpec +from avito.jobs.models import ( + ApplicationActionRequest, + ApplicationIdsQuery, + ApplicationIdsRequest, + ApplicationIdsResult, + ApplicationsResult, + ApplicationStatesResult, + ApplicationViewedRequest, + JobActionResult, + JobDictionariesResult, + JobDictionaryValuesResult, + JobWebhookInfo, + JobWebhooksResult, + JobWebhookUpdateRequest, + ResumeContactInfo, + ResumeInfo, + ResumeSearchQuery, + ResumesResult, + VacanciesQuery, + VacanciesResult, + VacancyArchiveRequest, + VacancyAutoRenewalRequest, + VacancyClassicCreateRequest, + VacancyClassicUpdateRequest, + VacancyCreateRequest, + VacancyIdsRequest, + VacancyInfo, + VacancyProlongateRequest, + VacancyStatusesResult, + VacancyUpdateRequest, +) + +CREATE_VACANCY_CLASSIC = OperationSpec( + name="jobs.vacancies.create_classic", + method="POST", + path="/job/v1/vacancies", + request_model=VacancyClassicCreateRequest, + response_model=JobActionResult, + retry_mode="enabled", +) +CREATE_VACANCY = OperationSpec( + name="jobs.vacancies.create", + method="POST", + path="/job/v2/vacancies", + request_model=VacancyCreateRequest, + response_model=JobActionResult, + retry_mode="enabled", +) +UPDATE_VACANCY_CLASSIC = OperationSpec( + name="jobs.vacancies.update_classic", + method="PUT", + path="/job/v1/vacancies/{vacancy_id}", + request_model=VacancyClassicUpdateRequest, + response_model=JobActionResult, + retry_mode="enabled", +) +UPDATE_VACANCY = OperationSpec( + name="jobs.vacancies.update", + method="POST", + path="/job/v2/vacancies/update/{vacancy_uuid}", + request_model=VacancyUpdateRequest, + response_model=JobActionResult, + retry_mode="enabled", +) +ARCHIVE_VACANCY = OperationSpec( + name="jobs.vacancies.archive", + method="PUT", + path="/job/v1/vacancies/archived/{vacancy_id}", + request_model=VacancyArchiveRequest, + response_model=JobActionResult, + retry_mode="enabled", +) +PROLONGATE_VACANCY = OperationSpec( + name="jobs.vacancies.prolongate", + method="POST", + path="/job/v1/vacancies/{vacancy_id}/prolongate", + request_model=VacancyProlongateRequest, + response_model=JobActionResult, + retry_mode="enabled", +) +LIST_VACANCIES = OperationSpec( + name="jobs.vacancies.list", + method="GET", + path="/job/v2/vacancies", + query_model=VacanciesQuery, + response_model=VacanciesResult, +) +GET_VACANCY = OperationSpec( + name="jobs.vacancies.get_item", + method="GET", + path="/job/v2/vacancies/{vacancy_id}", + query_model=VacanciesQuery, + response_model=VacancyInfo, +) +GET_VACANCIES_BY_IDS = OperationSpec( + name="jobs.vacancies.get_by_ids", + method="POST", + path="/job/v2/vacancies/batch", + request_model=VacancyIdsRequest, + response_model=VacanciesResult, + retry_mode="enabled", +) +GET_VACANCY_STATUSES = OperationSpec( + name="jobs.vacancies.get_statuses", + method="POST", + path="/job/v2/vacancies/statuses", + request_model=VacancyIdsRequest, + response_model=VacancyStatusesResult, + retry_mode="enabled", +) +UPDATE_VACANCY_AUTO_RENEWAL = OperationSpec( + name="jobs.vacancies.update_auto_renewal", + method="PUT", + path="/job/v2/vacancies/{vacancy_uuid}/auto_renewal", + request_model=VacancyAutoRenewalRequest, + response_model=JobActionResult, + retry_mode="enabled", +) +APPLY_APPLICATION_ACTIONS = OperationSpec( + name="jobs.applications.apply_actions", + method="POST", + path="/job/v1/applications/apply_actions", + request_model=ApplicationActionRequest, + response_model=JobActionResult, + retry_mode="enabled", +) +GET_APPLICATIONS_BY_IDS = OperationSpec( + name="jobs.applications.get_by_ids", + method="POST", + path="/job/v1/applications/get_by_ids", + request_model=ApplicationIdsRequest, + response_model=ApplicationsResult, + retry_mode="enabled", +) +GET_APPLICATION_IDS = OperationSpec( + name="jobs.applications.get_ids", + method="GET", + path="/job/v1/applications/get_ids", + query_model=ApplicationIdsQuery, + response_model=ApplicationIdsResult, +) +GET_APPLICATION_STATES = OperationSpec( + name="jobs.applications.get_states", + method="GET", + path="/job/v1/applications/get_states", + response_model=ApplicationStatesResult, +) +SET_APPLICATIONS_IS_VIEWED = OperationSpec( + name="jobs.applications.set_is_viewed", + method="POST", + path="/job/v1/applications/set_is_viewed", + request_model=ApplicationViewedRequest, + response_model=JobActionResult, + retry_mode="enabled", +) +SEARCH_RESUMES = OperationSpec( + name="jobs.resumes.search", + method="GET", + path="/job/v1/resumes/", + query_model=ResumeSearchQuery, + response_model=ResumesResult, +) +GET_RESUME = OperationSpec( + name="jobs.resumes.get_item", + method="GET", + path="/job/v2/resumes/{resume_id}", + response_model=ResumeInfo, +) +GET_RESUME_CONTACTS = OperationSpec( + name="jobs.resumes.get_contacts", + method="GET", + path="/job/v1/resumes/{resume_id}/contacts/", + response_model=ResumeContactInfo, +) +GET_JOB_WEBHOOK = OperationSpec( + name="jobs.webhook.get", + method="GET", + path="/job/v1/applications/webhook", + response_model=JobWebhookInfo, +) +LIST_JOB_WEBHOOKS = OperationSpec( + name="jobs.webhook.list", + method="GET", + path="/job/v1/applications/webhooks", + response_model=JobWebhooksResult, +) +UPDATE_JOB_WEBHOOK = OperationSpec( + name="jobs.webhook.put", + method="PUT", + path="/job/v1/applications/webhook", + request_model=JobWebhookUpdateRequest, + response_model=JobWebhookInfo, + retry_mode="enabled", +) +DELETE_JOB_WEBHOOK = OperationSpec( + name="jobs.webhook.delete", + method="DELETE", + path="/job/v1/applications/webhook", + response_model=JobActionResult, + retry_mode="enabled", +) +LIST_JOB_DICTIONARIES = OperationSpec( + name="jobs.dictionaries.list_dicts", + method="GET", + path="/job/v2/vacancy/dict", + response_model=JobDictionariesResult, +) +GET_JOB_DICTIONARY = OperationSpec( + name="jobs.dictionaries.get_dict_by_id", + method="GET", + path="/job/v2/vacancy/dict/{dictionary_id}", + response_model=JobDictionaryValuesResult, +) + +__all__ = ( + "APPLY_APPLICATION_ACTIONS", + "ARCHIVE_VACANCY", + "CREATE_VACANCY", + "CREATE_VACANCY_CLASSIC", + "DELETE_JOB_WEBHOOK", + "GET_APPLICATIONS_BY_IDS", + "GET_APPLICATION_IDS", + "GET_APPLICATION_STATES", + "GET_JOB_DICTIONARY", + "GET_JOB_WEBHOOK", + "GET_RESUME", + "GET_RESUME_CONTACTS", + "GET_VACANCIES_BY_IDS", + "GET_VACANCY", + "GET_VACANCY_STATUSES", + "LIST_JOB_DICTIONARIES", + "LIST_JOB_WEBHOOKS", + "LIST_VACANCIES", + "PROLONGATE_VACANCY", + "SEARCH_RESUMES", + "SET_APPLICATIONS_IS_VIEWED", + "UPDATE_JOB_WEBHOOK", + "UPDATE_VACANCY", + "UPDATE_VACANCY_AUTO_RENEWAL", + "UPDATE_VACANCY_CLASSIC", +) diff --git a/avito/messenger/__init__.py b/avito/messenger/__init__.py index 43b7d1c..e6530a0 100644 --- a/avito/messenger/__init__.py +++ b/avito/messenger/__init__.py @@ -7,31 +7,29 @@ ChatWebhook, SpecialOfferCampaign, ) -from avito.messenger.enums import ( - MessageActionStatus, - MessageDirection, - MessageType, - SpecialOfferCampaignStatus, - SpecialOfferDispatchStatus, - SubscriptionStatus, - WebhookStatus, -) from avito.messenger.models import ( ChatInfo, ChatsResult, MessageActionResult, + MessageActionStatus, + MessageDirection, MessageInfo, MessagesResult, + MessageType, MultiCreateSpecialOfferResult, SpecialOfferAvailableResult, + SpecialOfferCampaignStatus, + SpecialOfferDispatchStatus, SpecialOfferStatsResult, SubscriptionsResult, + SubscriptionStatus, TariffInfo, UploadImageFile, UploadImagesRequest, UploadImagesResult, VoiceFilesResult, WebhookActionResult, + WebhookStatus, ) __all__ = ( diff --git a/avito/messenger/client.py b/avito/messenger/client.py deleted file mode 100644 index 7187fd4..0000000 --- a/avito/messenger/client.py +++ /dev/null @@ -1,359 +0,0 @@ -"""Внутренние section clients для пакета messenger.""" - -from __future__ import annotations - -from collections.abc import Sequence -from dataclasses import dataclass - -from avito.core import RequestContext, Transport -from avito.messenger.mappers import ( - map_available_special_offers, - map_chat, - map_chats, - map_message_action, - map_messages, - map_multi_create_result, - map_special_offer_stats, - map_subscriptions, - map_tariff_info, - map_upload_images, - map_voice_files, - map_webhook_action, -) -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, -) - - -@dataclass(slots=True, frozen=True) -class MessengerClient: - """Выполняет HTTP-операции чатов и сообщений.""" - - transport: Transport - - def list_chats(self, *, user_id: int) -> ChatsResult: - """Получает список чатов пользователя.""" - - return self.transport.request_public_model( - "GET", - f"/messenger/v2/accounts/{user_id}/chats", - context=RequestContext("messenger.list_chats"), - mapper=map_chats, - ) - - def get_chat(self, *, user_id: int, chat_id: str) -> ChatInfo: - """Получает информацию по чату.""" - - return self.transport.request_public_model( - "GET", - f"/messenger/v2/accounts/{user_id}/chats/{chat_id}", - context=RequestContext("messenger.get_chat"), - mapper=map_chat, - ) - - def read_chat( - self, - *, - user_id: int, - chat_id: str, - idempotency_key: str | None = None, - ) -> MessageActionResult: - """Помечает чат как прочитанный.""" - - return self.transport.request_public_model( - "POST", - f"/messenger/v1/accounts/{user_id}/chats/{chat_id}/read", - context=RequestContext("messenger.read_chat", allow_retry=idempotency_key is not None), - mapper=map_message_action, - idempotency_key=idempotency_key, - ) - - def add_to_blacklist( - self, *, user_id: int, blacklisted_user_id: int, idempotency_key: str | None = None - ) -> MessageActionResult: - """Добавляет пользователя в blacklist.""" - - return self.transport.request_public_model( - "POST", - f"/messenger/v2/accounts/{user_id}/blacklist", - 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, - ) - - def send_message( - self, *, user_id: int, chat_id: str, message: str, idempotency_key: str | None = None - ) -> MessageActionResult: - """Отправляет текстовое сообщение.""" - - return self.transport.request_public_model( - "POST", - f"/messenger/v1/accounts/{user_id}/chats/{chat_id}/messages", - 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, - ) - - def send_image_message( - self, - *, - user_id: int, - chat_id: str, - image_id: str, - caption: str | None = None, - idempotency_key: str | None = None, - ) -> MessageActionResult: - """Отправляет сообщение с изображением.""" - - 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=idempotency_key is not None, - ), - mapper=map_message_action, - json_body=SendImageMessageRequest(image_id=image_id, caption=caption).to_payload(), - idempotency_key=idempotency_key, - ) - - def delete_message( - self, - *, - user_id: int, - chat_id: str, - message_id: str, - idempotency_key: str | None = None, - ) -> MessageActionResult: - """Удаляет сообщение.""" - - 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=idempotency_key is not None, - ), - mapper=map_message_action, - idempotency_key=idempotency_key, - ) - - def list_messages(self, *, user_id: int, chat_id: str) -> MessagesResult: - """Получает список сообщений V3.""" - - 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, - ) - - -@dataclass(slots=True, frozen=True) -class WebhookClient: - """Выполняет HTTP-операции webhook мессенджера.""" - - transport: Transport - - def get_subscriptions(self) -> SubscriptionsResult: - """Получает список подписок webhook.""" - - return self.transport.request_public_model( - "POST", - "/messenger/v1/subscriptions", - context=RequestContext("messenger.webhook.get_subscriptions", allow_retry=True), - mapper=map_subscriptions, - ) - - def unsubscribe(self, *, url: str, idempotency_key: str | None = None) -> WebhookActionResult: - """Отключает webhook.""" - - return self.transport.request_public_model( - "POST", - "/messenger/v1/webhook/unsubscribe", - 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, - ) - - def update_v3( - self, - *, - url: str, - secret: str | None = None, - idempotency_key: str | None = None, - ) -> WebhookActionResult: - """Включает уведомления webhook v3.""" - - return self.transport.request_public_model( - "POST", - "/messenger/v3/webhook", - 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, - ) - - -@dataclass(slots=True, frozen=True) -class MediaClient: - """Выполняет HTTP-операции media uploads и voice files.""" - - transport: Transport - - def get_voice_files( - self, - *, - user_id: int, - voice_ids: Sequence[str] | None = None, - ) -> VoiceFilesResult: - """Получает голосовые сообщения.""" - - resolved_voice_ids = list(voice_ids or ["voice-1"]) - 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, - params={"voice_ids": ",".join(resolved_voice_ids)}, - ) - - def upload_images( - self, - *, - user_id: int, - files: list[UploadImageFile], - idempotency_key: str | None = None, - ) -> UploadImagesResult: - """Загружает изображения для сообщений.""" - - return self.transport.request_public_model( - "POST", - f"/messenger/v1/accounts/{user_id}/uploadImages", - 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, - ) - - -@dataclass(slots=True, frozen=True) -class SpecialOffersClient: - """Выполняет HTTP-операции рассылок скидок и спецпредложений.""" - - transport: Transport - - def get_available(self, *, item_ids: list[int]) -> SpecialOfferAvailableResult: - """Получает доступные объявления для рассылки.""" - - return self.transport.request_public_model( - "POST", - "/special-offers/v1/available", - context=RequestContext("messenger.special_offers.get_available", allow_retry=True), - mapper=map_available_special_offers, - json_body=SpecialOfferAvailableRequest(item_ids=item_ids).to_payload(), - ) - - def create_multi( - self, - *, - item_ids: list[int], - message: str, - discount_percent: int | None = None, - idempotency_key: str | None = None, - ) -> MultiCreateSpecialOfferResult: - """Создает рассылку спецпредложений.""" - - return self.transport.request_public_model( - "POST", - "/special-offers/v1/multiCreate", - 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, - ) - - def confirm_multi( - self, *, campaign_id: str, idempotency_key: str | None = None - ) -> WebhookActionResult: - """Подтверждает и оплачивает рассылку.""" - - return self.transport.request_public_model( - "POST", - "/special-offers/v1/multiConfirm", - 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, - ) - - def get_stats(self, *, campaign_id: str) -> SpecialOfferStatsResult: - """Получает статистику рассылки.""" - - return self.transport.request_public_model( - "POST", - "/special-offers/v1/stats", - context=RequestContext("messenger.special_offers.get_stats", allow_retry=True), - mapper=map_special_offer_stats, - json_body=SpecialOfferStatsRequest(campaign_id=campaign_id).to_payload(), - ) - - def get_tariff_info(self) -> TariffInfo: - """Получает информацию о тарифе спецпредложений.""" - - 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, - ) - - -__all__ = ("MediaClient", "MessengerClient", "SpecialOffersClient", "WebhookClient") diff --git a/avito/messenger/domain.py b/avito/messenger/domain.py index 6aa27b1..bcf3012 100644 --- a/avito/messenger/domain.py +++ b/avito/messenger/domain.py @@ -5,25 +5,55 @@ from collections.abc import Sequence from dataclasses import dataclass -from avito.core import ValidationError +from avito.core import ApiTimeouts, RetryOverride, ValidationError from avito.core.domain import DomainObject from avito.core.swagger import swagger_operation -from avito.messenger.client import MediaClient, MessengerClient, SpecialOffersClient, WebhookClient +from avito.core.validation import DateInput, serialize_iso_datetime from avito.messenger.models import ( + BlacklistRequest, ChatInfo, ChatsResult, MessageActionResult, MessagesResult, + MultiConfirmSpecialOfferRequest, + MultiCreateSpecialOfferRequest, MultiCreateSpecialOfferResult, + SendImageMessageRequest, + SendMessageRequest, + SpecialOfferAvailableRequest, SpecialOfferAvailableResult, + SpecialOfferStatsRequest, SpecialOfferStatsResult, SubscriptionsResult, TariffInfo, + UnsubscribeWebhookRequest, + UpdateWebhookRequest, UploadImageFile, + UploadImagesRequest, UploadImagesResult, VoiceFilesResult, WebhookActionResult, ) +from avito.messenger.operations import ( + ADD_TO_BLACKLIST, + CONFIRM_MULTI_SPECIAL_OFFER, + CREATE_MULTI_SPECIAL_OFFER, + DELETE_MESSAGE, + GET_AVAILABLE_SPECIAL_OFFERS, + GET_CHAT, + GET_SPECIAL_OFFER_STATS, + GET_SPECIAL_OFFER_TARIFF_INFO, + GET_SUBSCRIPTIONS, + GET_VOICE_FILES, + LIST_CHATS, + LIST_MESSAGES, + READ_CHAT, + SEND_IMAGE_MESSAGE, + SEND_MESSAGE, + UNSUBSCRIBE_WEBHOOK, + UPDATE_WEBHOOK_V3, + UPLOAD_IMAGES, +) @dataclass(slots=True, frozen=True) @@ -43,15 +73,33 @@ class Chat(DomainObject): spec="Мессенджер.json", operation_id="getChatByIdV2", ) - def get(self) -> ChatInfo: + def get( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> ChatInfo: """Получает чат по `chat_id`. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ChatInfo` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return MessengerClient(self.transport).get_chat( - user_id=self._require_user_id(), - chat_id=self._require_chat_id(), + return self._execute( + GET_CHAT, + path_params={ + "user_id": self._require_user_id(), + "chat_id": self._require_chat_id(), + }, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -60,15 +108,31 @@ def get(self) -> ChatInfo: spec="Мессенджер.json", operation_id="getChatsV2", ) - def list(self) -> ChatsResult: - """Получает список чатов пользователя. + def list( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> ChatsResult: + """Возвращает список чатов. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Возвращает: + `ChatsResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return MessengerClient(self.transport).list_chats(user_id=self._require_user_id()) + return self._execute( + LIST_CHATS, + path_params={"user_id": self._require_user_id()}, + timeout=timeout, + retry=retry, + ) @swagger_operation( "POST", @@ -76,18 +140,40 @@ def list(self) -> ChatsResult: spec="Мессенджер.json", operation_id="chatRead", ) - def mark_read(self, *, idempotency_key: str | None = None) -> MessageActionResult: + def mark_read( + self, + *, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MessageActionResult: """Помечает чат как прочитанный. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MessageActionResult` с типизированными данными ответа. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return MessengerClient(self.transport).read_chat( - user_id=self._require_user_id(), - chat_id=self._require_chat_id(), + return self._execute( + READ_CHAT, + path_params={ + "user_id": self._require_user_id(), + "chat_id": self._require_chat_id(), + }, idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -95,25 +181,42 @@ def mark_read(self, *, idempotency_key: str | None = None) -> MessageActionResul "/messenger/v2/accounts/{user_id}/blacklist", spec="Мессенджер.json", operation_id="postBlacklistV2", - method_args={"blacklisted_user_id": "body.users"}, + method_args={"blacklisted_user_id": "body.users[].user_id"}, ) def blacklist( self, *, blacklisted_user_id: int, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> MessageActionResult: """Добавляет пользователя в blacklist. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + blacklisted_user_id: идентификатор пользователя, которого нужно добавить в черный список. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MessageActionResult` с типизированными данными ответа. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return MessengerClient(self.transport).add_to_blacklist( - user_id=self._require_user_id(), - blacklisted_user_id=blacklisted_user_id, + return self._execute( + ADD_TO_BLACKLIST, + path_params={"user_id": self._require_user_id()}, + request=BlacklistRequest(blacklisted_user_id=blacklisted_user_id), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) def _require_user_id(self) -> int: @@ -145,21 +248,42 @@ class ChatMessage(DomainObject): @swagger_operation( "GET", - "/messenger/v3/accounts/{user_id}/chats/{chat_id}/messages", + "/messenger/v3/accounts/{user_id}/chats/{chat_id}/messages/", spec="Мессенджер.json", operation_id="getMessagesV3", ) - def list(self, *, chat_id: str | None = None) -> MessagesResult: - """Получает список сообщений V3. + def list( + self, + *, + chat_id: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MessagesResult: + """Возвращает список сообщений чата. + + Аргументы: + chat_id: идентифицирует чат. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MessagesResult` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return MessengerClient(self.transport).list_messages( - user_id=self._require_user_id(), - chat_id=chat_id or self._require_chat_id(), + return self._execute( + LIST_MESSAGES, + path_params={ + "user_id": self._require_user_id(), + "chat_id": chat_id or self._require_chat_id(), + }, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -175,19 +299,39 @@ def send_message( chat_id: str | None = None, message: str, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> MessageActionResult: """Отправляет текстовое сообщение. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + chat_id: идентификатор чата. + message: текст отправляемого сообщения. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Возвращает: + `MessageActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return MessengerClient(self.transport).send_message( - user_id=self._require_user_id(), - chat_id=chat_id or self._require_chat_id(), - message=message, + return self._execute( + SEND_MESSAGE, + path_params={ + "user_id": self._require_user_id(), + "chat_id": chat_id or self._require_chat_id(), + }, + request=SendMessageRequest(message=message), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -204,20 +348,40 @@ def send_image( image_id: str, caption: str | None = None, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> MessageActionResult: """Отправляет сообщение с изображением. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + chat_id: идентификатор чата. + image_id: идентификатор изображения для отправки. + caption: подпись к изображению, если поддерживается операцией. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MessageActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return MessengerClient(self.transport).send_image_message( - user_id=self._require_user_id(), - chat_id=chat_id or self._require_chat_id(), - image_id=image_id, - caption=caption, + return self._execute( + SEND_IMAGE_MESSAGE, + path_params={ + "user_id": self._require_user_id(), + "chat_id": chat_id or self._require_chat_id(), + }, + request=SendImageMessageRequest(image_id=image_id, caption=caption), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -232,20 +396,40 @@ def delete( chat_id: str | None = None, message_id: str | None = None, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> MessageActionResult: """Удаляет сообщение. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + chat_id: идентификатор чата. + message_id: идентификатор сообщения. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Возвращает: + `MessageActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ resolved_message_id = message_id or self._require_message_id() - return MessengerClient(self.transport).delete_message( - user_id=self._require_user_id(), - chat_id=chat_id or self._require_chat_id(), - message_id=resolved_message_id, + return self._execute( + DELETE_MESSAGE, + path_params={ + "user_id": self._require_user_id(), + "chat_id": chat_id or self._require_chat_id(), + "message_id": resolved_message_id, + }, idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) def _require_user_id(self) -> int: @@ -279,15 +463,26 @@ class ChatWebhook(DomainObject): spec="Мессенджер.json", operation_id="getSubscriptions", ) - def list(self) -> SubscriptionsResult: - """Получает список webhook-подписок. + def list( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> SubscriptionsResult: + """Возвращает список webhook-подписок чатов. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `SubscriptionsResult` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return WebhookClient(self.transport).get_subscriptions() + return self._execute(GET_SUBSCRIPTIONS, timeout=timeout, retry=retry) @swagger_operation( "POST", @@ -296,15 +491,40 @@ def list(self) -> SubscriptionsResult: operation_id="postWebhookUnsubscribe", method_args={"url": "body.url"}, ) - def unsubscribe(self, *, url: str, idempotency_key: str | None = None) -> WebhookActionResult: + def unsubscribe( + self, + *, + url: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> WebhookActionResult: """Отключает webhook. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + url: URL источника данных. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `WebhookActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return WebhookClient(self.transport).unsubscribe(url=url, idempotency_key=idempotency_key) + return self._execute( + UNSUBSCRIBE_WEBHOOK, + request=UnsubscribeWebhookRequest(url=url), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) @swagger_operation( "POST", @@ -319,18 +539,35 @@ def subscribe( url: str, secret: str | None = None, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> WebhookActionResult: """Включает webhook v3. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + url: URL источника данных. + secret: секрет webhook-подписки для проверки входящих событий. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `WebhookActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return WebhookClient(self.transport).update_v3( - url=url, - secret=secret, + return self._execute( + UPDATE_WEBHOOK_V3, + request=UpdateWebhookRequest(url=url, secret=secret), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @@ -354,15 +591,33 @@ def get_voice_files( self, *, voice_ids: Sequence[str] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> VoiceFilesResult: """Получает голосовые сообщения. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + voice_ids: идентификаторы голосовых файлов. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `VoiceFilesResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return MediaClient(self.transport).get_voice_files( - user_id=self._require_user_id(), - voice_ids=voice_ids, + resolved_voice_ids = list(voice_ids or ["voice-1"]) + return self._execute( + GET_VOICE_FILES, + path_params={"user_id": self._require_user_id()}, + query={"voice_ids": ",".join(resolved_voice_ids)}, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -377,18 +632,35 @@ def upload_images( *, files: list[UploadImageFile], idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> UploadImagesResult: """Загружает изображения для сообщений. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + files: файлы изображений для загрузки. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `UploadImagesResult` с типизированными данными ответа. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return MediaClient(self.transport).upload_images( - user_id=self._require_user_id(), - files=files, + return self._execute( + UPLOAD_IMAGES, + path_params={"user_id": self._require_user_id()}, + files=UploadImagesRequest(files=files).to_files(), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) def _require_user_id(self) -> int: @@ -415,41 +687,73 @@ class SpecialOfferCampaign(DomainObject): operation_id="openApiAvailable", method_args={"item_ids": "body.item_ids"}, ) - def get_available(self, *, item_ids: list[int]) -> SpecialOfferAvailableResult: + def get_available( + self, + *, + item_ids: list[int], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> SpecialOfferAvailableResult: """Получает объявления, доступные для рассылки. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `SpecialOfferAvailableResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SpecialOffersClient(self.transport).get_available(item_ids=item_ids) + return self._execute( + GET_AVAILABLE_SPECIAL_OFFERS, + request=SpecialOfferAvailableRequest(item_ids=item_ids), + timeout=timeout, + retry=retry, + ) @swagger_operation( "POST", "/special-offers/v1/multiCreate", spec="Рассылкаскидокиспецпредложенийвмессенджере.json", operation_id="openApiMultiCreate", - method_args={"item_ids": "body.item_ids", "message": "body.item_ids"}, + method_args={"item_ids": "body.itemIds"}, ) def create_multi( self, *, item_ids: list[int], - message: str, - discount_percent: int | None = None, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> MultiCreateSpecialOfferResult: """Создает рассылку спецпредложений. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + item_ids: передает список объявлений для рассылки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Возвращает: + `MultiCreateSpecialOfferResult` с идентификатором и статусом рассылки. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SpecialOffersClient(self.transport).create_multi( - item_ids=item_ids, - message=message, - discount_percent=discount_percent, + return self._execute( + CREATE_MULTI_SPECIAL_OFFER, + request=MultiCreateSpecialOfferRequest(item_ids=item_ids), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -457,23 +761,55 @@ def create_multi( "/special-offers/v1/multiConfirm", spec="Рассылкаскидокиспецпредложенийвмессенджере.json", operation_id="openApiMultiConfirm", + method_args={ + "dispatch_id": "body.dispatches[].dispatchId", + "recipients_count": "body.dispatches[].recipientsCount", + "offer_slug": "body.dispatches[].offerSlug", + }, ) def confirm_multi( self, *, - campaign_id: str | None = None, + dispatch_id: int, + recipients_count: int, + offer_slug: str, + discount_value: int | None = None, + expires_at: int | None = None, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> WebhookActionResult: """Подтверждает и оплачивает рассылку. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + dispatch_id: идентифицирует рассылку. + recipients_count: задает число получателей рассылки. + offer_slug: задает выбранный вариант предложения. + discount_value: задает финальный размер скидки, если он применим. + expires_at: задает timestamp окончания предложения. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `WebhookActionResult` со статусом подтверждения. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SpecialOffersClient(self.transport).confirm_multi( - campaign_id=campaign_id or self._require_campaign_id(), + return self._execute( + CONFIRM_MULTI_SPECIAL_OFFER, + request=MultiConfirmSpecialOfferRequest( + dispatch_id=dispatch_id, + recipients_count=recipients_count, + offer_slug=offer_slug, + discount_value=discount_value, + expires_at=expires_at, + ), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -481,15 +817,42 @@ def confirm_multi( "/special-offers/v1/stats", spec="Рассылкаскидокиспецпредложенийвмессенджере.json", operation_id="openApiStats", + method_args={ + "date_time_from": "body.dateTimeFrom", + "date_time_to": "body.dateTimeTo", + }, ) - def get_stats(self, *, campaign_id: str | None = None) -> SpecialOfferStatsResult: + def get_stats( + self, + *, + date_time_from: DateInput, + date_time_to: DateInput, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> SpecialOfferStatsResult: """Получает статистику рассылки. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + date_time_from: задает начало периода в формате RFC3339. + date_time_to: задает конец периода в формате RFC3339. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `SpecialOfferStatsResult` со статистикой рассылки. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SpecialOffersClient(self.transport).get_stats( - campaign_id=campaign_id or self._require_campaign_id() + return self._execute( + GET_SPECIAL_OFFER_STATS, + request=SpecialOfferStatsRequest( + date_time_from=serialize_iso_datetime("date_time_from", date_time_from), + date_time_to=serialize_iso_datetime("date_time_to", date_time_to), + ), + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -498,13 +861,26 @@ def get_stats(self, *, campaign_id: str | None = None) -> SpecialOfferStatsResul spec="Рассылкаскидокиспецпредложенийвмессенджере.json", operation_id="openApiTariffInfo", ) - def get_tariff_info(self) -> TariffInfo: + def get_tariff_info( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> TariffInfo: """Получает информацию о тарифе спецпредложений. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `TariffInfo` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SpecialOffersClient(self.transport).get_tariff_info() + return self._execute(GET_SPECIAL_OFFER_TARIFF_INFO, timeout=timeout, retry=retry) def _require_campaign_id(self) -> str: if self.campaign_id is None: diff --git a/avito/messenger/enums.py b/avito/messenger/enums.py deleted file mode 100644 index 29aa15d..0000000 --- a/avito/messenger/enums.py +++ /dev/null @@ -1,75 +0,0 @@ -"""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" - NOT_CREATED = "notCreated" - CREATED = "created" - - -class SpecialOfferDispatchStatus(str, Enum): - """Статус рассылки спецпредложений.""" - - UNKNOWN = "__unknown__" - DRAFT = "draft" - CONFIRMED = "confirmed" - NOT_CREATED = "notCreated" - CREATED = "created" - - -__all__ = ( - "MessageActionStatus", - "MessageDirection", - "MessageType", - "SpecialOfferCampaignStatus", - "SpecialOfferDispatchStatus", - "SubscriptionStatus", - "WebhookStatus", -) diff --git a/avito/messenger/mappers.py b/avito/messenger/mappers.py deleted file mode 100644 index bb02a3d..0000000 --- a/avito/messenger/mappers.py +++ /dev/null @@ -1,310 +0,0 @@ -"""Мапперы JSON -> dataclass для пакета messenger.""" - -from __future__ import annotations - -from collections.abc import Mapping -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, - SpecialOfferDispatchStatus, - SubscriptionStatus, - WebhookStatus, -) -from avito.messenger.models import ( - ChatInfo, - ChatsResult, - MessageActionResult, - MessageInfo, - MessagesResult, - MultiCreateSpecialOfferResult, - SpecialOfferAvailableItem, - SpecialOfferAvailableResult, - SpecialOfferStatsResult, - SubscriptionInfo, - SubscriptionsResult, - TariffInfo, - UploadImageResult, - UploadImagesResult, - VoiceFile, - VoiceFilesResult, - WebhookActionResult, -) - -Payload = Mapping[str, object] - - -def _expect_mapping(payload: object) -> Payload: - if not isinstance(payload, Mapping): - raise ResponseMappingError("Ожидался JSON-объект.", payload=payload) - return cast(Payload, payload) - - -def _list(payload: Payload, *keys: str) -> list[Payload]: - for key in keys: - value = payload.get(key) - if isinstance(value, list): - return [item for item in value if isinstance(item, Mapping)] - return [] - - -def _str(payload: Payload, *keys: str) -> str | None: - for key in keys: - value = payload.get(key) - if isinstance(value, str): - return value - return None - - -def _datetime(payload: Payload, *keys: str) -> datetime | None: - for key in keys: - value = payload.get(key) - if isinstance(value, str): - normalized = value.replace("Z", "+00:00") - try: - return datetime.fromisoformat(normalized) - except ValueError: - continue - return None - - -def _int(payload: Payload, *keys: str) -> int | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - continue - if isinstance(value, int): - return value - return None - - -def _float(payload: Payload, *keys: str) -> float | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - continue - if isinstance(value, (int, float)): - return float(value) - return None - - -def _bool(payload: Payload, *keys: str) -> bool | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - return value - return None - - -def map_chat(payload: object) -> ChatInfo: - """Преобразует объект чата в dataclass.""" - - data = _expect_mapping(payload) - last_message = data.get("last_message") - last_message_data = cast(Payload, last_message) if isinstance(last_message, Mapping) else {} - return ChatInfo( - chat_id=_str(data, "id", "chat_id", "chatId"), - user_id=_int(data, "user_id", "userId"), - title=_str(data, "title", "name"), - unread_count=_int(data, "unread_count", "unreadCount"), - last_message_text=_str(last_message_data, "text", "message"), - ) - - -def map_chats(payload: object) -> ChatsResult: - """Преобразует список чатов в dataclass.""" - - data = _expect_mapping(payload) - return ChatsResult( - items=[map_chat(item) for item in _list(data, "chats", "items", "result")], - total=_int(data, "total", "count"), - ) - - -def map_message(payload: object) -> MessageInfo: - """Преобразует сообщение в dataclass.""" - - data = _expect_mapping(payload) - return MessageInfo( - message_id=_str(data, "id", "message_id", "messageId"), - chat_id=_str(data, "chat_id", "chatId"), - author_id=_int(data, "author_id", "authorId", "user_id", "userId"), - text=_str(data, "text", "message"), - created_at=_datetime(data, "created_at", "createdAt"), - 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", - ), - ) - - -def map_messages(payload: object) -> MessagesResult: - """Преобразует список сообщений в dataclass.""" - - data = _expect_mapping(payload) - return MessagesResult( - items=[map_message(item) for item in _list(data, "messages", "items", "result")], - total=_int(data, "total", "count"), - ) - - -def map_message_action(payload: object) -> MessageActionResult: - """Преобразует результат операции с сообщением.""" - - data = _expect_mapping(payload) - return MessageActionResult( - success=bool(data.get("success", True)), - message_id=_str(data, "message_id", "messageId", "id"), - status=map_enum_or_unknown( - _str(data, "status", "message"), - MessageActionStatus, - enum_name="messenger.message_action_status", - ), - ) - - -def map_voice_files(payload: object) -> VoiceFilesResult: - """Преобразует список голосовых сообщений.""" - - data = _expect_mapping(payload) - return VoiceFilesResult( - items=[ - VoiceFile( - id=_str(item, "id", "voice_id", "voiceId"), - url=_str(item, "url"), - duration=_int(item, "duration"), - transcript=_str(item, "transcript", "text"), - ) - for item in _list(data, "voice_files", "items", "result") - ], - ) - - -def map_upload_images(payload: object) -> UploadImagesResult: - """Преобразует результат загрузки изображений.""" - - data = _expect_mapping(payload) - return UploadImagesResult( - items=[ - UploadImageResult( - image_id=_str(item, "image_id", "imageId", "id"), - url=_str(item, "url"), - ) - for item in _list(data, "images", "items", "result") - ], - ) - - -def map_subscriptions(payload: object) -> SubscriptionsResult: - """Преобразует список подписок webhook.""" - - data = _expect_mapping(payload) - return SubscriptionsResult( - items=[ - SubscriptionInfo( - url=_str(item, "url"), - version=_str(item, "version"), - status=map_enum_or_unknown( - _str(item, "status"), - SubscriptionStatus, - enum_name="messenger.subscription_status", - ), - ) - for item in _list(data, "subscriptions", "items", "result") - ], - ) - - -def map_webhook_action(payload: object) -> WebhookActionResult: - """Преобразует результат операции с webhook.""" - - data = _expect_mapping(payload) - return WebhookActionResult( - success=bool(data.get("success", True)), - status=map_enum_or_unknown( - _str(data, "status", "message"), - WebhookStatus, - enum_name="messenger.webhook_status", - ), - ) - - -def map_available_special_offers(payload: object) -> SpecialOfferAvailableResult: - """Преобразует доступные объявления спецпредложений.""" - - data = _expect_mapping(payload) - return SpecialOfferAvailableResult( - items=[ - SpecialOfferAvailableItem( - item_id=_int(item, "item_id", "itemId", "id"), - title=_str(item, "title"), - is_available=_bool(item, "is_available", "isAvailable", "available"), - ) - for item in _list(data, "items", "result") - ], - ) - - -def map_multi_create_result(payload: object) -> MultiCreateSpecialOfferResult: - """Преобразует результат создания рассылки.""" - - data = _expect_mapping(payload) - return MultiCreateSpecialOfferResult( - campaign_id=_str(data, "campaign_id", "campaignId", "id"), - status=map_enum_or_unknown( - _str(data, "status"), - SpecialOfferDispatchStatus, - enum_name="messenger.special_offer_dispatch_status", - ), - ) - - -def map_special_offer_stats(payload: object) -> SpecialOfferStatsResult: - """Преобразует статистику рассылки.""" - - data = _expect_mapping(payload) - return SpecialOfferStatsResult( - campaign_id=_str(data, "campaign_id", "campaignId", "id"), - sent_count=_int(data, "sent_count", "sentCount"), - delivered_count=_int(data, "delivered_count", "deliveredCount"), - read_count=_int(data, "read_count", "readCount"), - ) - - -def map_tariff_info(payload: object) -> TariffInfo: - """Преобразует информацию о тарифе.""" - - data = _expect_mapping(payload) - return TariffInfo( - price=_float(data, "price", "amount"), - currency=_str(data, "currency"), - daily_limit=_int(data, "daily_limit", "dailyLimit", "limit"), - ) - - -__all__ = ( - "map_available_special_offers", - "map_chat", - "map_chats", - "map_message", - "map_message_action", - "map_messages", - "map_multi_create_result", - "map_special_offer_stats", - "map_subscriptions", - "map_tariff_info", - "map_upload_images", - "map_voice_files", - "map_webhook_action", -) diff --git a/avito/messenger/models.py b/avito/messenger/models.py index 59eb726..b4f0708 100644 --- a/avito/messenger/models.py +++ b/avito/messenger/models.py @@ -2,23 +2,79 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime +from enum import Enum from typing import BinaryIO -from avito.core.serialization import SerializableModel -from avito.messenger.enums import ( - MessageActionStatus, - MessageDirection, - MessageType, - SpecialOfferDispatchStatus, - SubscriptionStatus, - WebhookStatus, -) +from avito.core import ApiModel, RequestModel +from avito.core.exceptions import ResponseMappingError + +Payload = Mapping[str, object] + + +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" + NOT_CREATED = "notCreated" + CREATED = "created" + + +class SpecialOfferDispatchStatus(str, Enum): + """Статус рассылки спецпредложений.""" + + UNKNOWN = "__unknown__" + DRAFT = "draft" + CONFIRMED = "confirmed" + NOT_CREATED = "notCreated" + CREATED = "created" @dataclass(slots=True, frozen=True) -class ChatInfo(SerializableModel): +class ChatInfo(ApiModel): """Информация о чате.""" chat_id: str | None @@ -27,17 +83,42 @@ class ChatInfo(SerializableModel): unread_count: int | None last_message_text: str | None + @classmethod + def from_payload(cls, payload: object) -> ChatInfo: + """Преобразует объект чата в SDK-модель.""" + + data = _expect_mapping(payload) + last_message = data.get("last_message") + last_message_data = last_message if isinstance(last_message, Mapping) else {} + return cls( + chat_id=_str(data, "id", "chat_id", "chatId"), + user_id=_int(data, "user_id", "userId"), + title=_str(data, "title", "name"), + unread_count=_int(data, "unread_count", "unreadCount"), + last_message_text=_str(last_message_data, "text", "message"), + ) + @dataclass(slots=True, frozen=True) -class ChatsResult(SerializableModel): +class ChatsResult(ApiModel): """Список чатов.""" items: list[ChatInfo] total: int | None = None + @classmethod + def from_payload(cls, payload: object) -> ChatsResult: + """Преобразует список чатов в SDK-модель.""" + + data = _expect_mapping(payload) + return cls( + items=[ChatInfo.from_payload(item) for item in _list(data, "chats", "items", "result")], + total=_int(data, "total", "count"), + ) + @dataclass(slots=True, frozen=True) -class SendMessageRequest: +class SendMessageRequest(RequestModel): """Запрос отправки текстового сообщения.""" message: str @@ -48,13 +129,16 @@ def to_payload(self) -> dict[str, object]: return { key: value - for key, value in {"message": self.message, "type": self.type}.items() + for key, value in { + "message": {"text": self.message}, + "type": (self.type or MessageType.TEXT).value, + }.items() if value is not None } @dataclass(slots=True, frozen=True) -class SendImageMessageRequest: +class SendImageMessageRequest(RequestModel): """Запрос отправки сообщения с изображением.""" image_id: str @@ -65,13 +149,13 @@ def to_payload(self) -> dict[str, object]: return { key: value - for key, value in {"imageId": self.image_id, "caption": self.caption}.items() + for key, value in {"image_id": self.image_id, "caption": self.caption}.items() if value is not None } @dataclass(slots=True, frozen=True) -class MessageInfo(SerializableModel): +class MessageInfo(ApiModel): """Информация о сообщении чата.""" message_id: str | None @@ -82,26 +166,74 @@ class MessageInfo(SerializableModel): direction: MessageDirection | None type: MessageType | None + @classmethod + def from_payload(cls, payload: object) -> MessageInfo: + """Преобразует сообщение в SDK-модель.""" + + data = _expect_mapping(payload) + return cls( + message_id=_str(data, "id", "message_id", "messageId"), + chat_id=_str(data, "chat_id", "chatId"), + author_id=_int(data, "author_id", "authorId", "user_id", "userId"), + text=_str(data, "text", "message"), + created_at=_datetime(data, "created_at", "createdAt"), + direction=_enum(MessageDirection, _str(data, "direction")), + type=_enum(MessageType, _str(data, "type")), + ) + @dataclass(slots=True, frozen=True) -class MessagesResult(SerializableModel): +class MessagesResult(ApiModel): """Список сообщений чата.""" items: list[MessageInfo] total: int | None = None + @classmethod + def from_payload(cls, payload: object) -> MessagesResult: + """Преобразует список сообщений в SDK-модель.""" + + if isinstance(payload, list): + return cls( + items=[ + MessageInfo.from_payload(item) + for item in payload + if isinstance(item, Mapping) + ] + ) + + data = _expect_mapping(payload) + return cls( + items=[ + MessageInfo.from_payload(item) + for item in _list(data, "messages", "items", "result") + ], + total=_int(data, "total", "count"), + ) + @dataclass(slots=True, frozen=True) -class MessageActionResult(SerializableModel): +class MessageActionResult(ApiModel): """Результат операции с сообщением или чатом.""" success: bool message_id: str | None = None status: MessageActionStatus | None = None + @classmethod + def from_payload(cls, payload: object) -> MessageActionResult: + """Преобразует результат операции с сообщением.""" + + data = _expect_mapping(payload) + return cls( + success=bool(data.get("success", True)), + message_id=_str(data, "message_id", "messageId", "id"), + status=_enum(MessageActionStatus, _str(data, "status", "message")), + ) + @dataclass(slots=True, frozen=True) -class VoiceFile(SerializableModel): +class VoiceFile(ApiModel): """Голосовое сообщение.""" id: str | None @@ -111,14 +243,31 @@ class VoiceFile(SerializableModel): @dataclass(slots=True, frozen=True) -class VoiceFilesResult(SerializableModel): +class VoiceFilesResult(ApiModel): """Список голосовых сообщений.""" items: list[VoiceFile] + @classmethod + def from_payload(cls, payload: object) -> VoiceFilesResult: + """Преобразует список голосовых сообщений.""" + + data = _expect_mapping(payload) + return cls( + items=[ + VoiceFile( + id=_str(item, "id", "voice_id", "voiceId"), + url=_str(item, "url"), + duration=_int(item, "duration"), + transcript=_str(item, "transcript", "text"), + ) + for item in _list(data, "voice_files", "items", "result") + ], + ) + @dataclass(slots=True, frozen=True) -class UploadImageResult(SerializableModel): +class UploadImageResult(ApiModel): """Результат загрузки изображения.""" image_id: str | None @@ -126,11 +275,26 @@ class UploadImageResult(SerializableModel): @dataclass(slots=True, frozen=True) -class UploadImagesResult(SerializableModel): +class UploadImagesResult(ApiModel): """Список загруженных изображений.""" items: list[UploadImageResult] + @classmethod + def from_payload(cls, payload: object) -> UploadImagesResult: + """Преобразует результат загрузки изображений.""" + + data = _expect_mapping(payload) + return cls( + items=[ + UploadImageResult( + image_id=_str(item, "image_id", "imageId", "id"), + url=_str(item, "url"), + ) + for item in _list(data, "images", "items", "result") + ], + ) + @dataclass(slots=True, frozen=True) class UploadImageFile: @@ -157,7 +321,7 @@ def to_files(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class SubscriptionInfo(SerializableModel): +class SubscriptionInfo(ApiModel): """Подписка webhook мессенджера.""" url: str | None @@ -166,14 +330,30 @@ class SubscriptionInfo(SerializableModel): @dataclass(slots=True, frozen=True) -class SubscriptionsResult(SerializableModel): +class SubscriptionsResult(ApiModel): """Список webhook-подписок.""" items: list[SubscriptionInfo] + @classmethod + def from_payload(cls, payload: object) -> SubscriptionsResult: + """Преобразует список подписок webhook.""" + + data = _expect_mapping(payload) + return cls( + items=[ + SubscriptionInfo( + url=_str(item, "url"), + version=_str(item, "version"), + status=_enum(SubscriptionStatus, _str(item, "status")), + ) + for item in _list(data, "subscriptions", "items", "result") + ], + ) + @dataclass(slots=True, frozen=True) -class UnsubscribeWebhookRequest: +class UnsubscribeWebhookRequest(RequestModel): """Запрос отключения webhook.""" url: str @@ -185,7 +365,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class UpdateWebhookRequest: +class UpdateWebhookRequest(RequestModel): """Запрос включения webhook v3.""" url: str @@ -202,15 +382,25 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class WebhookActionResult(SerializableModel): +class WebhookActionResult(ApiModel): """Результат операции с webhook.""" success: bool status: WebhookStatus | None = None + @classmethod + def from_payload(cls, payload: object) -> WebhookActionResult: + """Преобразует результат операции с webhook.""" + + data = _expect_mapping(payload) + return cls( + success=bool(data.get("success", True)), + status=_enum(WebhookStatus, _str(data, "status", "message")), + ) + @dataclass(slots=True, frozen=True) -class BlacklistRequest: +class BlacklistRequest(RequestModel): """Запрос добавления пользователя в blacklist.""" blacklisted_user_id: int @@ -218,11 +408,11 @@ class BlacklistRequest: def to_payload(self) -> dict[str, object]: """Сериализует запрос blacklist.""" - return {"blacklistedUserId": self.blacklisted_user_id} + return {"users": [{"user_id": self.blacklisted_user_id}]} @dataclass(slots=True, frozen=True) -class SpecialOfferAvailableRequest: +class SpecialOfferAvailableRequest(RequestModel): """Запрос доступных объявлений для спецпредложений.""" item_ids: list[int] @@ -234,7 +424,7 @@ def to_payload(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class SpecialOfferAvailableItem(SerializableModel): +class SpecialOfferAvailableItem(ApiModel): """Доступное объявление для рассылки спецпредложений.""" item_id: int | None @@ -243,68 +433,102 @@ class SpecialOfferAvailableItem(SerializableModel): @dataclass(slots=True, frozen=True) -class SpecialOfferAvailableResult(SerializableModel): +class SpecialOfferAvailableResult(ApiModel): """Результат получения доступных объявлений.""" items: list[SpecialOfferAvailableItem] + @classmethod + def from_payload(cls, payload: object) -> SpecialOfferAvailableResult: + """Преобразует доступные объявления спецпредложений.""" + + data = _expect_mapping(payload) + return cls( + items=[ + SpecialOfferAvailableItem( + item_id=_int(item, "item_id", "itemId", "id"), + title=_str(item, "title"), + is_available=_bool(item, "is_available", "isAvailable", "available"), + ) + for item in _list(data, "items", "result") + ], + ) + @dataclass(slots=True, frozen=True) -class MultiCreateSpecialOfferRequest: +class MultiCreateSpecialOfferRequest(RequestModel): """Запрос создания рассылки.""" item_ids: list[int] - message: str - discount_percent: int | None = None def to_payload(self) -> dict[str, object]: """Сериализует запрос создания рассылки.""" - return { - key: value - for key, value in { - "itemIds": self.item_ids, - "message": self.message, - "discountPercent": self.discount_percent, - }.items() - if value is not None - } + return {"itemIds": self.item_ids} @dataclass(slots=True, frozen=True) -class MultiCreateSpecialOfferResult(SerializableModel): +class MultiCreateSpecialOfferResult(ApiModel): """Результат создания рассылки.""" campaign_id: str | None status: SpecialOfferDispatchStatus | None + @classmethod + def from_payload(cls, payload: object) -> MultiCreateSpecialOfferResult: + """Преобразует результат создания рассылки.""" + + data = _expect_mapping(payload) + return cls( + campaign_id=_str(data, "campaign_id", "campaignId", "id"), + status=_enum(SpecialOfferDispatchStatus, _str(data, "status")), + ) + @dataclass(slots=True, frozen=True) -class MultiConfirmSpecialOfferRequest: +class MultiConfirmSpecialOfferRequest(RequestModel): """Запрос подтверждения и оплаты рассылки.""" - campaign_id: str + dispatch_id: int + recipients_count: int + offer_slug: str + discount_value: int | None = None + expires_at: int | None = None def to_payload(self) -> dict[str, object]: """Сериализует запрос подтверждения рассылки.""" - return {"campaignId": self.campaign_id} + dispatch = { + key: value + for key, value in { + "dispatchId": self.dispatch_id, + "recipientsCount": self.recipients_count, + "offerSlug": self.offer_slug, + "discountValue": self.discount_value, + }.items() + if value is not None + } + payload: dict[str, object] = {"dispatches": [dispatch]} + if self.expires_at is not None: + payload["expiresAt"] = self.expires_at + return payload @dataclass(slots=True, frozen=True) -class SpecialOfferStatsRequest: +class SpecialOfferStatsRequest(RequestModel): """Запрос статистики рассылки.""" - campaign_id: str + date_time_from: str + date_time_to: str def to_payload(self) -> dict[str, object]: """Сериализует запрос статистики рассылки.""" - return {"campaignId": self.campaign_id} + return {"dateTimeFrom": self.date_time_from, "dateTimeTo": self.date_time_to} @dataclass(slots=True, frozen=True) -class SpecialOfferStatsResult(SerializableModel): +class SpecialOfferStatsResult(ApiModel): """Статистика рассылки.""" campaign_id: str | None @@ -312,23 +536,119 @@ class SpecialOfferStatsResult(SerializableModel): delivered_count: int | None read_count: int | None + @classmethod + def from_payload(cls, payload: object) -> SpecialOfferStatsResult: + """Преобразует статистику рассылки.""" + + data = _expect_mapping(payload) + return cls( + campaign_id=_str(data, "campaign_id", "campaignId", "id"), + sent_count=_int(data, "sent_count", "sentCount"), + delivered_count=_int(data, "delivered_count", "deliveredCount"), + read_count=_int(data, "read_count", "readCount"), + ) + @dataclass(slots=True, frozen=True) -class TariffInfo(SerializableModel): +class TariffInfo(ApiModel): """Информация о тарифе рассылок.""" price: float | None currency: str | None daily_limit: int | None + @classmethod + def from_payload(cls, payload: object) -> TariffInfo: + """Преобразует информацию о тарифе.""" + + data = _expect_mapping(payload) + return cls( + price=_float(data, "price", "amount"), + currency=_str(data, "currency"), + daily_limit=_int(data, "daily_limit", "dailyLimit", "limit"), + ) + + +def _expect_mapping(payload: object) -> Payload: + if not isinstance(payload, Mapping): + raise ResponseMappingError("Ожидался JSON-объект.", payload=payload) + return payload + + +def _list(payload: Payload, *keys: str) -> list[Payload]: + for key in keys: + value = payload.get(key) + if isinstance(value, list): + return [item for item in value if isinstance(item, Mapping)] + return [] + + +def _str(payload: Payload, *keys: str) -> str | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str): + return value + return None + + +def _datetime(payload: Payload, *keys: str) -> datetime | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str): + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + continue + return None + + +def _int(payload: Payload, *keys: str) -> int | None: + for key in keys: + value = payload.get(key) + if isinstance(value, bool): + continue + if isinstance(value, int): + return value + return None + + +def _float(payload: Payload, *keys: str) -> float | None: + for key in keys: + value = payload.get(key) + if isinstance(value, bool): + continue + if isinstance(value, int | float): + return float(value) + return None + + +def _bool(payload: Payload, *keys: str) -> bool | None: + for key in keys: + value = payload.get(key) + if isinstance(value, bool): + return value + return None + + +def _enum[EnumT: Enum](enum_type: type[EnumT], value: str | None) -> EnumT | None: + if value is None: + return None + try: + return enum_type(value) + except ValueError: + return enum_type("__unknown__") + __all__ = ( "BlacklistRequest", "ChatInfo", "ChatsResult", "MessageActionResult", + "MessageActionStatus", + "MessageDirection", "MessageInfo", "MessagesResult", + "MessageType", "MultiConfirmSpecialOfferRequest", "MultiCreateSpecialOfferRequest", "MultiCreateSpecialOfferResult", @@ -337,9 +657,12 @@ class TariffInfo(SerializableModel): "SpecialOfferAvailableItem", "SpecialOfferAvailableRequest", "SpecialOfferAvailableResult", + "SpecialOfferCampaignStatus", + "SpecialOfferDispatchStatus", "SpecialOfferStatsRequest", "SpecialOfferStatsResult", "SubscriptionInfo", + "SubscriptionStatus", "SubscriptionsResult", "TariffInfo", "UnsubscribeWebhookRequest", @@ -351,4 +674,5 @@ class TariffInfo(SerializableModel): "VoiceFile", "VoiceFilesResult", "WebhookActionResult", + "WebhookStatus", ) diff --git a/avito/messenger/operations.py b/avito/messenger/operations.py new file mode 100644 index 0000000..c631402 --- /dev/null +++ b/avito/messenger/operations.py @@ -0,0 +1,181 @@ +"""Operation specs for messenger domain.""" + +from __future__ import annotations + +from avito.core import OperationSpec +from avito.messenger.models import ( + BlacklistRequest, + ChatInfo, + ChatsResult, + MessageActionResult, + MessagesResult, + MultiConfirmSpecialOfferRequest, + MultiCreateSpecialOfferRequest, + MultiCreateSpecialOfferResult, + SendImageMessageRequest, + SendMessageRequest, + SpecialOfferAvailableRequest, + SpecialOfferAvailableResult, + SpecialOfferStatsRequest, + SpecialOfferStatsResult, + SubscriptionsResult, + TariffInfo, + UnsubscribeWebhookRequest, + UpdateWebhookRequest, + UploadImagesResult, + VoiceFilesResult, + WebhookActionResult, +) + +LIST_CHATS = OperationSpec( + name="messenger.list_chats", + method="GET", + path="/messenger/v2/accounts/{user_id}/chats", + response_model=ChatsResult, +) +GET_CHAT = OperationSpec( + name="messenger.get_chat", + method="GET", + path="/messenger/v2/accounts/{user_id}/chats/{chat_id}", + response_model=ChatInfo, +) +READ_CHAT = OperationSpec( + name="messenger.read_chat", + method="POST", + path="/messenger/v1/accounts/{user_id}/chats/{chat_id}/read", + response_model=MessageActionResult, + retry_mode="enabled", +) +ADD_TO_BLACKLIST = OperationSpec( + name="messenger.add_to_blacklist", + method="POST", + path="/messenger/v2/accounts/{user_id}/blacklist", + request_model=BlacklistRequest, + response_model=MessageActionResult, + retry_mode="enabled", +) +LIST_MESSAGES = OperationSpec( + name="messenger.list_messages", + method="GET", + path="/messenger/v3/accounts/{user_id}/chats/{chat_id}/messages/", + response_model=MessagesResult, +) +SEND_MESSAGE = OperationSpec( + name="messenger.send_message", + method="POST", + path="/messenger/v1/accounts/{user_id}/chats/{chat_id}/messages", + request_model=SendMessageRequest, + response_model=MessageActionResult, + retry_mode="enabled", +) +SEND_IMAGE_MESSAGE = OperationSpec( + name="messenger.send_image_message", + method="POST", + path="/messenger/v1/accounts/{user_id}/chats/{chat_id}/messages/image", + request_model=SendImageMessageRequest, + response_model=MessageActionResult, + retry_mode="enabled", +) +DELETE_MESSAGE = OperationSpec( + name="messenger.delete_message", + method="POST", + path="/messenger/v1/accounts/{user_id}/chats/{chat_id}/messages/{message_id}", + response_model=MessageActionResult, + retry_mode="enabled", +) +GET_SUBSCRIPTIONS = OperationSpec( + name="messenger.webhook.get_subscriptions", + method="POST", + path="/messenger/v1/subscriptions", + response_model=SubscriptionsResult, + retry_mode="enabled", +) +UNSUBSCRIBE_WEBHOOK = OperationSpec( + name="messenger.webhook.unsubscribe", + method="POST", + path="/messenger/v1/webhook/unsubscribe", + request_model=UnsubscribeWebhookRequest, + response_model=WebhookActionResult, + retry_mode="enabled", +) +UPDATE_WEBHOOK_V3 = OperationSpec( + name="messenger.webhook.update_v3", + method="POST", + path="/messenger/v3/webhook", + request_model=UpdateWebhookRequest, + response_model=WebhookActionResult, + retry_mode="enabled", +) +GET_VOICE_FILES = OperationSpec( + name="messenger.media.get_voice_files", + method="GET", + path="/messenger/v1/accounts/{user_id}/getVoiceFiles", + response_model=VoiceFilesResult, +) +UPLOAD_IMAGES = OperationSpec( + name="messenger.media.upload_images", + method="POST", + path="/messenger/v1/accounts/{user_id}/uploadImages", + response_model=UploadImagesResult, + retry_mode="enabled", +) +GET_AVAILABLE_SPECIAL_OFFERS = OperationSpec( + name="messenger.special_offers.get_available", + method="POST", + path="/special-offers/v1/available", + request_model=SpecialOfferAvailableRequest, + response_model=SpecialOfferAvailableResult, + retry_mode="enabled", +) +CREATE_MULTI_SPECIAL_OFFER = OperationSpec( + name="messenger.special_offers.create_multi", + method="POST", + path="/special-offers/v1/multiCreate", + request_model=MultiCreateSpecialOfferRequest, + response_model=MultiCreateSpecialOfferResult, + retry_mode="enabled", +) +CONFIRM_MULTI_SPECIAL_OFFER = OperationSpec( + name="messenger.special_offers.confirm_multi", + method="POST", + path="/special-offers/v1/multiConfirm", + request_model=MultiConfirmSpecialOfferRequest, + response_model=WebhookActionResult, + retry_mode="enabled", +) +GET_SPECIAL_OFFER_STATS = OperationSpec( + name="messenger.special_offers.get_stats", + method="POST", + path="/special-offers/v1/stats", + request_model=SpecialOfferStatsRequest, + response_model=SpecialOfferStatsResult, + retry_mode="enabled", +) +GET_SPECIAL_OFFER_TARIFF_INFO = OperationSpec( + name="messenger.special_offers.get_tariff_info", + method="POST", + path="/special-offers/v1/tariffInfo", + response_model=TariffInfo, + retry_mode="enabled", +) + +__all__ = ( + "ADD_TO_BLACKLIST", + "CONFIRM_MULTI_SPECIAL_OFFER", + "CREATE_MULTI_SPECIAL_OFFER", + "DELETE_MESSAGE", + "GET_AVAILABLE_SPECIAL_OFFERS", + "GET_CHAT", + "GET_SPECIAL_OFFER_STATS", + "GET_SPECIAL_OFFER_TARIFF_INFO", + "GET_SUBSCRIPTIONS", + "GET_VOICE_FILES", + "LIST_CHATS", + "LIST_MESSAGES", + "READ_CHAT", + "SEND_IMAGE_MESSAGE", + "SEND_MESSAGE", + "UNSUBSCRIBE_WEBHOOK", + "UPDATE_WEBHOOK_V3", + "UPLOAD_IMAGES", +) diff --git a/avito/orders/__init__.py b/avito/orders/__init__.py index 874b952..c023173 100644 --- a/avito/orders/__init__.py +++ b/avito/orders/__init__.py @@ -8,16 +8,6 @@ SandboxDelivery, Stock, ) -from avito.orders.enums import ( - DeliveryOperationStatus, - DeliveryStatus, - DeliveryTaskState, - LabelTaskStatus, - OrderActionStatus, - OrderStatus, - TrackingAvitoEventType, - TrackingAvitoStatus, -) from avito.orders.models import ( AddSortingCentersRequest, AddTariffV2Request, @@ -36,15 +26,18 @@ DeliveryDirection, DeliveryDirectionZone, DeliveryEntityResult, + DeliveryOperationStatus, DeliveryParcelIdsRequest, DeliveryParcelRequest, DeliveryParcelResultRequest, DeliveryRestriction, DeliverySortingCentersResult, + DeliveryStatus, DeliveryTariffItem, DeliveryTariffValue, DeliveryTariffZone, DeliveryTaskInfo, + DeliveryTaskState, DeliveryTerms, DeliveryTermsZone, DeliveryTrackingOptions, @@ -54,8 +47,10 @@ GetSandboxParcelInfoRequest, LabelPdfResult, LabelTaskResult, + LabelTaskStatus, OrderAcceptReturnRequest, OrderActionResult, + OrderActionStatus, OrderApplyTransitionRequest, OrderCncDetailsRequest, OrderConfirmationCodeRequest, @@ -64,7 +59,9 @@ OrderLabelsRequest, OrderMarkingsRequest, OrdersResult, + OrderStatus, OrderTrackingNumberRequest, + OrderTransition, ProhibitOrderAcceptanceRequest, RealAddress, SandboxAnnouncementDelivery, @@ -130,6 +127,7 @@ "OrderActionStatus", "OrderStatus", "OrderTrackingNumberRequest", + "OrderTransition", "OrdersResult", "ProhibitOrderAcceptanceRequest", "RealAddress", diff --git a/avito/orders/client.py b/avito/orders/client.py deleted file mode 100644 index c357b6c..0000000 --- a/avito/orders/client.py +++ /dev/null @@ -1,810 +0,0 @@ -"""Внутренние section clients для пакета orders.""" - -from __future__ import annotations - -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, - map_delivery_task, - map_label_task, - map_order_action, - map_orders, - map_sorting_centers, - map_stock_info, - map_stock_update, -) -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, -) - - -@dataclass(slots=True, frozen=True) -class OrdersClient: - """Выполняет HTTP-операции управления заказами.""" - - transport: Transport - - def list_orders(self) -> OrdersResult: - return self.transport.request_public_model( - "GET", - "/order-management/1/orders", - context=RequestContext("orders.list_orders"), - mapper=map_orders, - ) - - 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, - *, - 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", - OrderAcceptReturnRequest(order_id=order_id, postal_office_id=postal_office_id), - idempotency_key=idempotency_key, - ) - - 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", - OrderApplyTransitionRequest(order_id=order_id, transition=transition), - idempotency_key=idempotency_key, - ) - - 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", - OrderConfirmationCodeRequest(order_id=order_id, code=code), - idempotency_key=idempotency_key, - ) - - 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", - OrderCncDetailsRequest(order_id=order_id, pickup_point_id=pickup_point_id), - idempotency_key=idempotency_key, - ) - - def get_courier_delivery_range( - self, - *, - order_id: str = "order-1", - address: str | None = None, - ) -> CourierRangesResult: - params: dict[str, object] = {"orderId": order_id} - if address is not None: - params["address"] = address - return self.transport.request_public_model( - "GET", - "/order-management/1/order/getCourierDeliveryRange", - context=RequestContext("orders.get_courier_delivery_range"), - mapper=map_courier_ranges, - params=params, - ) - - 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", - OrderCourierRangeRequest(order_id=order_id, interval_id=interval_id), - idempotency_key=idempotency_key, - ) - - 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", - OrderTrackingNumberRequest(order_id=order_id, tracking_number=tracking_number), - idempotency_key=idempotency_key, - ) - - def _post_action( - self, - path: str, - operation: str, - request: OrderMarkingsRequest - | OrderAcceptReturnRequest - | OrderApplyTransitionRequest - | OrderConfirmationCodeRequest - | OrderCncDetailsRequest - | OrderCourierRangeRequest - | OrderTrackingNumberRequest, - idempotency_key: str | None = None, - ) -> OrderActionResult: - return self.transport.request_public_model( - "POST", - path, - context=RequestContext(operation, allow_retry=idempotency_key is not None), - mapper=map_order_action, - json_body=request.to_payload(), - idempotency_key=idempotency_key, - ) - - -@dataclass(slots=True, frozen=True) -class LabelsClient: - """Выполняет операции генерации и загрузки PDF-этикеток.""" - - transport: Transport - - 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, *, order_ids: list[str], idempotency_key: str | None = None - ) -> LabelTaskResult: - return self._create( - "/order-management/1/orders/labels/extended", - "orders.labels.create_extended", - OrderLabelsRequest(order_ids=order_ids), - idempotency_key=idempotency_key, - ) - - def get_download_label(self, *, task_id: str) -> LabelPdfResult: - binary = self.transport.download_binary( - f"/order-management/1/orders/labels/{task_id}/download", - context=RequestContext("orders.labels.download"), - ) - return LabelPdfResult(binary=binary) - - 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=idempotency_key is not None), - mapper=map_label_task, - json_body=request.to_payload(), - idempotency_key=idempotency_key, - ) - - -@dataclass(slots=True, frozen=True) -class DeliveryClient: - """Выполняет production-операции доставки.""" - - transport: Transport - - 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, *, 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, - *, - 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, *, parcel_id: str, result: str, idempotency_key: str | None = None - ) -> DeliveryEntityResult: - return self._post( - "/delivery/order/changeParcelResult", - "orders.delivery.change_parcel_result", - DeliveryParcelResultRequest(parcel_id=parcel_id, result=result), - idempotency_key=idempotency_key, - ) - - 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", - DeliveryParcelIdsRequest(parcel_ids=parcel_ids), - idempotency_key=idempotency_key, - ) - - def _post( - self, - path: str, - operation: str, - request: DeliveryAnnouncementRequest - | DeliveryParcelRequest - | DeliveryParcelResultRequest - | DeliveryParcelIdsRequest, - idempotency_key: str | None = None, - ) -> DeliveryEntityResult: - return self.transport.request_public_model( - "POST", - path, - context=RequestContext(operation, allow_retry=idempotency_key is not None), - mapper=map_delivery_entity, - json_body=request.to_payload(), - idempotency_key=idempotency_key, - ) - - -@dataclass(slots=True, frozen=True) -class SandboxDeliveryClient: - """Выполняет sandbox-операции доставки.""" - - transport: Transport - - def create_announcement( - self, *, order_id: str, idempotency_key: str | None = None - ) -> DeliveryEntityResult: - return self._post( - "/delivery-sandbox/announcements/create", - "orders.sandbox.create_announcement", - DeliveryAnnouncementRequest(order_id=order_id), - idempotency_key=idempotency_key, - ) - - def track_announcement( - self, *, order_id: str, idempotency_key: str | None = None - ) -> DeliveryEntityResult: - return self._post( - "/delivery-sandbox/announcements/track", - "orders.sandbox.track_announcement", - DeliveryAnnouncementRequest(order_id=order_id), - idempotency_key=idempotency_key, - ) - - def update_custom_area_schedule( - self, *, items: list[CustomAreaScheduleEntry], idempotency_key: str | None = None - ) -> DeliveryEntityResult: - return self._post( - "/delivery-sandbox/areas/custom-schedule", - "orders.sandbox.update_custom_area_schedule", - CustomAreaScheduleRequest(items=items), - idempotency_key=idempotency_key, - ) - - 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, *, parcel_id: str, confirm_code: str, idempotency_key: str | None = None - ) -> DeliveryEntityResult: - return self._post( - "/delivery-sandbox/order/checkConfirmationCode", - "orders.sandbox.check_confirmation_code", - SandboxConfirmationCodeRequest(parcel_id=parcel_id, confirm_code=confirm_code), - idempotency_key=idempotency_key, - ) - - 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", - SetOrderPropertiesRequest(order_id=order_id, properties=properties), - idempotency_key=idempotency_key, - ) - - 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", - SetOrderRealAddressRequest(order_id=order_id, address=address), - idempotency_key=idempotency_key, - ) - - 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, *, order_id: str, idempotency_key: str | None = None - ) -> DeliveryEntityResult: - return self._post( - "/delivery-sandbox/prohibitOrderAcceptance", - "orders.sandbox.prohibit_order_acceptance", - ProhibitOrderAcceptanceRequest(order_id=order_id), - idempotency_key=idempotency_key, - ) - - def list_sorting_center( - self, - *, - delivery_providers: list[str] | None = None, - ) -> DeliverySortingCentersResult: - providers = delivery_providers or ["pochta"] - return self.transport.request_public_model( - "GET", - "/delivery-sandbox/sorting-center", - context=RequestContext("orders.sandbox.list_sorting_center"), - mapper=map_sorting_centers, - params={"deliveryProviders": ",".join(providers)}, - ) - - 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", - AddSortingCentersRequest(items=items), - idempotency_key=idempotency_key, - ) - - 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", - SandboxAreasRequest(areas=areas), - idempotency_key=idempotency_key, - ) - - def add_tags_to_sorting_center( - 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", - TaggedSortingCentersRequest(items=items), - idempotency_key=idempotency_key, - ) - - def add_terminals( - 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", - AddTerminalsRequest(items=items), - idempotency_key=idempotency_key, - ) - - 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", - UpdateTermsRequest(items=items), - idempotency_key=idempotency_key, - ) - - 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, - *, - 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", - SandboxCancelAnnouncementRequest( - announcement_id=announcement_id, - date=date, - options=options, - ), - idempotency_key=idempotency_key, - ) - - 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", - CancelSandboxParcelRequest(parcel_id=parcel_id, options=options), - idempotency_key=idempotency_key, - ) - - 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", - ChangeParcelRequest( - type=type, - parcel_id=parcel_id, - application=application, - options=options, - ), - idempotency_key=idempotency_key, - ) - - def create_sandbox_announcement( - 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", - 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, *, announcement_id: str, idempotency_key: str | None = None - ) -> DeliveryEntityResult: - return self._post( - "/delivery-sandbox/v1/getAnnouncementEvent", - "orders.sandbox.get_sandbox_announcement_event", - SandboxGetAnnouncementEventRequest(announcement_id=announcement_id), - idempotency_key=idempotency_key, - ) - - def get_sandbox_change_parcel_info( - self, *, application_id: str, idempotency_key: str | None = None - ) -> DeliveryEntityResult: - return self._post( - "/delivery-sandbox/v1/getChangeParcelInfo", - "orders.sandbox.get_sandbox_change_parcel_info", - GetChangeParcelInfoRequest(application_id=application_id), - idempotency_key=idempotency_key, - ) - - def get_sandbox_parcel_info( - self, *, parcel_id: str, idempotency_key: str | None = None - ) -> DeliveryEntityResult: - return self._post( - "/delivery-sandbox/v1/getParcelInfo", - "orders.sandbox.get_sandbox_parcel_info", - GetSandboxParcelInfoRequest(parcel_id=parcel_id), - idempotency_key=idempotency_key, - ) - - def get_sandbox_registered_parcel_id( - self, *, order_id: str, idempotency_key: str | None = None - ) -> DeliveryEntityResult: - return self._post( - "/delivery-sandbox/v1/getRegisteredParcelID", - "orders.sandbox.get_sandbox_registered_parcel_id", - GetRegisteredParcelIdRequest(order_id=order_id), - idempotency_key=idempotency_key, - ) - - 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", - DeliveryParcelRequest(order_id=order_id, parcel_id=parcel_id), - idempotency_key=idempotency_key, - ) - - def _post( - self, - path: str, - operation: str, - request: CustomAreaScheduleRequest - | CancelParcelRequest - | SandboxConfirmationCodeRequest - | SetOrderPropertiesRequest - | SetOrderRealAddressRequest - | DeliveryTrackingRequest - | ProhibitOrderAcceptanceRequest - | AddSortingCentersRequest - | DeliveryAnnouncementRequest - | SandboxAreasRequest - | TaggedSortingCentersRequest - | AddTerminalsRequest - | UpdateTermsRequest - | AddTariffV2Request - | SandboxCancelAnnouncementRequest - | CancelSandboxParcelRequest - | ChangeParcelRequest - | SandboxCreateAnnouncementRequest - | SandboxGetAnnouncementEventRequest - | GetChangeParcelInfoRequest - | GetSandboxParcelInfoRequest - | GetRegisteredParcelIdRequest - | DeliveryParcelRequest, - idempotency_key: str | None = None, - ) -> DeliveryEntityResult: - return self.transport.request_public_model( - "POST", - path, - context=RequestContext(operation, allow_retry=idempotency_key is not None), - mapper=map_delivery_entity, - json_body=request.to_payload(), - idempotency_key=idempotency_key, - ) - - -@dataclass(slots=True, frozen=True) -class DeliveryTasksClient: - """Получает статус задач delivery API.""" - - transport: Transport - - def get_task(self, *, task_id: str) -> DeliveryTaskInfo: - 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, - ) - - -@dataclass(slots=True, frozen=True) -class StockManagementClient: - """Выполняет операции управления остатками.""" - - transport: Transport - - 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=idempotency_key is not None), - mapper=map_stock_info, - json_body=StockInfoRequest(item_ids=item_ids).to_payload(), - idempotency_key=idempotency_key, - ) - - 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=idempotency_key is not None, - ), - mapper=map_stock_update, - json_body=StockUpdateRequest(stocks=stocks).to_payload(), - idempotency_key=idempotency_key, - ) - - -__all__ = ( - "DeliveryClient", - "DeliveryTasksClient", - "LabelsClient", - "OrdersClient", - "SandboxDeliveryClient", - "StockManagementClient", -) diff --git a/avito/orders/domain.py b/avito/orders/domain.py index 18f8b5b..8a9a78d 100644 --- a/avito/orders/domain.py +++ b/avito/orders/domain.py @@ -5,48 +5,129 @@ from collections.abc import Sequence from dataclasses import dataclass -from avito.core import ValidationError +from avito.core import ApiTimeouts, RetryOverride, ValidationError from avito.core.domain import DomainObject from avito.core.swagger import swagger_operation -from avito.orders.client import ( - DeliveryClient, - DeliveryTasksClient, - LabelsClient, - OrdersClient, - SandboxDeliveryClient, - StockManagementClient, -) -from avito.orders.enums import TrackingAvitoEventType, TrackingAvitoStatus +from avito.core.validation import DateInput, serialize_iso_datetime from avito.orders.models import ( + AddSortingCentersRequest, + AddTariffV2Request, + AddTerminalsRequest, + CancelParcelRequest, CancelSandboxParcelOptions, + CancelSandboxParcelRequest, ChangeParcelApplication, ChangeParcelOptions, + ChangeParcelRequest, CourierRangesResult, CustomAreaScheduleEntry, + CustomAreaScheduleRequest, + DeliveryAnnouncementRequest, + DeliveryAnnouncementTrackRequest, + DeliveryCancelAnnouncementRequest, DeliveryDirection, DeliveryEntityResult, + DeliveryParcelIdsRequest, + DeliveryParcelRequest, + DeliveryParcelResultRequest, + DeliverySandboxAnnouncementRequest, DeliverySortingCentersResult, DeliveryTariffZone, DeliveryTaskInfo, DeliveryTermsZone, DeliveryTrackingOptions, + DeliveryTrackingRequest, + GetChangeParcelInfoRequest, + GetRegisteredParcelIdRequest, + GetSandboxParcelInfoRequest, LabelPdfResult, LabelTaskResult, + OrderAcceptReturnRequest, OrderActionResult, + OrderApplyTransitionRequest, + OrderCncDetailsRequest, + OrderConfirmationCodeRequest, + OrderCourierRangeRequest, OrderDeliveryProperties, + OrderLabelsRequest, + OrderMarkingsRequest, OrdersResult, + OrderTrackingNumberRequest, + OrderTransition, + ProhibitOrderAcceptanceRequest, RealAddress, SandboxAnnouncementPackage, SandboxAnnouncementParticipant, SandboxArea, + SandboxAreasRequest, SandboxCancelAnnouncementOptions, + SandboxCancelAnnouncementRequest, + SandboxConfirmationCodeRequest, SandboxCreateAnnouncementOptions, + SandboxCreateAnnouncementRequest, + SandboxGetAnnouncementEventRequest, + SandboxParcelRequest, + SetOrderPropertiesRequest, + SetOrderRealAddressRequest, SortingCenterUpload, + StockInfoRequest, StockInfoResult, StockUpdateEntry, + StockUpdateRequest, StockUpdateResult, TaggedSortingCenter, + TaggedSortingCentersRequest, TerminalUpload, + TrackingAvitoEventType, + TrackingAvitoStatus, + UpdateTermsRequest, +) +from avito.orders.operations import ( + ACCEPT_RETURN_ORDER, + APPLY_TRANSITION, + CHECK_CONFIRMATION_CODE, + CREATE_LABELS, + CREATE_LABELS_EXTENDED, + DELIVERY_CANCEL_ANNOUNCEMENT, + DELIVERY_CHANGE_PARCEL_RESULT, + DELIVERY_CREATE_ANNOUNCEMENT, + DELIVERY_CREATE_PARCEL, + DELIVERY_UPDATE_CHANGE_PARCELS, + DOWNLOAD_LABEL, + GET_COURIER_DELIVERY_RANGE, + GET_DELIVERY_TASK, + GET_STOCK_INFO, + LIST_ORDERS, + SANDBOX_ADD_AREAS, + SANDBOX_ADD_SORTING_CENTER, + SANDBOX_ADD_TAGS_TO_SORTING_CENTER, + SANDBOX_ADD_TARIFF, + SANDBOX_ADD_TERMINALS, + SANDBOX_CANCEL_PARCEL, + SANDBOX_CANCEL_SANDBOX_ANNOUNCEMENT, + SANDBOX_CANCEL_SANDBOX_PARCEL, + SANDBOX_CHANGE_SANDBOX_PARCEL, + SANDBOX_CHECK_CONFIRMATION_CODE, + SANDBOX_CREATE_ANNOUNCEMENT, + SANDBOX_CREATE_PARCEL, + SANDBOX_CREATE_SANDBOX_ANNOUNCEMENT, + SANDBOX_GET_ANNOUNCEMENT_EVENT, + SANDBOX_GET_CHANGE_PARCEL_INFO, + SANDBOX_GET_PARCEL_INFO, + SANDBOX_GET_REGISTERED_PARCEL_ID, + SANDBOX_LIST_SORTING_CENTER, + SANDBOX_PROHIBIT_ORDER_ACCEPTANCE, + SANDBOX_SET_ORDER_PROPERTIES, + SANDBOX_SET_ORDER_REAL_ADDRESS, + SANDBOX_TRACK_ANNOUNCEMENT, + SANDBOX_TRACKING, + SANDBOX_UPDATE_CUSTOM_AREA_SCHEDULE, + SANDBOX_UPDATE_TERMS, + SET_CNC_DETAILS, + SET_COURIER_DELIVERY_RANGE, + SET_TRACKING_NUMBER, + UPDATE_MARKINGS, + UPDATE_STOCKS, ) @@ -65,15 +146,26 @@ class Order(DomainObject): spec="Управлениезаказами.json", operation_id="getOrders", ) - def list(self) -> OrdersResult: - """Выполняет публичную операцию `Order.list` и возвращает типизированную SDK-модель. + def list( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> OrdersResult: + """Возвращает список заказов. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `OrdersResult` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return OrdersClient(self.transport).list_orders() + return self._execute(LIST_ORDERS, timeout=timeout, retry=retry) @swagger_operation( "POST", @@ -83,21 +175,40 @@ def list(self) -> OrdersResult: method_args={"order_id": "body.markings", "codes": "body.markings"}, ) def update_markings( - self, *, order_id: str, codes: Sequence[str], idempotency_key: str | None = None + self, + *, + order_id: str, + codes: Sequence[str], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> OrderActionResult: - """Выполняет публичную операцию `Order.update_markings` и возвращает типизированную SDK-модель. + """Обновляет коды маркировки заказа. + + Аргументы: + order_id: идентифицирует заказ. + codes: передает коды маркировки заказа. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `OrderActionResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return OrdersClient(self.transport).update_markings( - order_id=order_id, - codes=list(codes), + return self._execute( + UPDATE_MARKINGS, + request=OrderMarkingsRequest(order_id=order_id, codes=list(codes)), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -108,21 +219,43 @@ def update_markings( method_args={"order_id": "body.order_id", "postal_office_id": "body.terminal_number"}, ) def accept_return_order( - self, *, order_id: str, postal_office_id: str, idempotency_key: str | None = None + self, + *, + order_id: str, + postal_office_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> OrderActionResult: - """Выполняет публичную операцию `Order.accept_return_order` и возвращает типизированную SDK-модель. + """Подтверждает return order для заказов. + + Аргументы: + order_id: идентифицирует заказ. + postal_office_id: идентифицирует почтовое отделение для возврата. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `OrderActionResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return OrdersClient(self.transport).accept_return_order( - order_id=order_id, - postal_office_id=postal_office_id, + return self._execute( + ACCEPT_RETURN_ORDER, + request=OrderAcceptReturnRequest( + order_id=order_id, + postal_office_id=postal_office_id, + ), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -133,21 +266,40 @@ def accept_return_order( method_args={"order_id": "body.order_id", "transition": "body.transition"}, ) def apply( - self, *, order_id: str, transition: str, idempotency_key: str | None = None + self, + *, + order_id: str, + transition: OrderTransition | str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> OrderActionResult: - """Выполняет публичную операцию `Order.apply` и возвращает типизированную SDK-модель. + """Применяет действие к заказов. + + Аргументы: + order_id: идентифицирует заказ. + transition: задает переход статуса заказа. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `OrderActionResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return OrdersClient(self.transport).apply_transition( - order_id=order_id, - transition=transition, + return self._execute( + APPLY_TRANSITION, + request=OrderApplyTransitionRequest(order_id=order_id, transition=transition), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -158,21 +310,40 @@ def apply( method_args={"order_id": "body.parcel_id", "code": "body.confirm_code"}, ) def check_confirmation_code( - self, *, order_id: str, code: str, idempotency_key: str | None = None + self, + *, + order_id: str, + code: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> OrderActionResult: - """Выполняет публичную операцию `Order.check_confirmation_code` и возвращает типизированную SDK-модель. + """Проверяет confirmation code для заказов. + + Аргументы: + order_id: идентифицирует заказ. + code: передает код подтверждения. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `OrderActionResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return OrdersClient(self.transport).check_confirmation_code( - order_id=order_id, - code=code, + return self._execute( + CHECK_CONFIRMATION_CODE, + request=OrderConfirmationCodeRequest(order_id=order_id, code=code), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -183,21 +354,40 @@ def check_confirmation_code( method_args={"order_id": "body.id", "pickup_point_id": "body.marketplace_id"}, ) def set_cnc_details( - self, *, order_id: str, pickup_point_id: str, idempotency_key: str | None = None + self, + *, + order_id: str, + pickup_point_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> OrderActionResult: - """Выполняет публичную операцию `Order.set_cnc_details` и возвращает типизированную SDK-модель. + """Устанавливает параметры click-and-collect для заказа. + + Аргументы: + order_id: идентифицирует заказ. + pickup_point_id: идентифицирует пункт выдачи click-and-collect. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `OrderActionResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return OrdersClient(self.transport).set_cnc_details( - order_id=order_id, - pickup_point_id=pickup_point_id, + return self._execute( + SET_CNC_DETAILS, + request=OrderCncDetailsRequest(order_id=order_id, pickup_point_id=pickup_point_id), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -206,15 +396,31 @@ def set_cnc_details( spec="Управлениезаказами.json", operation_id="getCourierDeliveryRange", ) - def get_courier_delivery_range(self) -> CourierRangesResult: - """Выполняет публичную операцию `Order.get_courier_delivery_range` и возвращает типизированную SDK-модель. + def get_courier_delivery_range( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> CourierRangesResult: + """Возвращает courier delivery range для заказов. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CourierRangesResult` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return OrdersClient(self.transport).get_courier_delivery_range() + return self._execute( + GET_COURIER_DELIVERY_RANGE, + query={"orderId": "order-1"}, + timeout=timeout, + retry=retry, + ) @swagger_operation( "POST", @@ -224,21 +430,40 @@ def get_courier_delivery_range(self) -> CourierRangesResult: method_args={"order_id": "body.order_id", "interval_id": "body.interval_type"}, ) def set_courier_delivery_range( - self, *, order_id: str, interval_id: str, idempotency_key: str | None = None + self, + *, + order_id: str, + interval_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> OrderActionResult: - """Выполняет публичную операцию `Order.set_courier_delivery_range` и возвращает типизированную SDK-модель. + """Устанавливает интервал курьерской доставки заказа. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + order_id: идентифицирует заказ. + interval_id: идентифицирует интервал курьерской доставки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `OrderActionResult` со статусом выполнения операции. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return OrdersClient(self.transport).set_courier_delivery_range( - order_id=order_id, - interval_id=interval_id, + return self._execute( + SET_COURIER_DELIVERY_RANGE, + request=OrderCourierRangeRequest(order_id=order_id, interval_id=interval_id), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -249,21 +474,43 @@ def set_courier_delivery_range( method_args={"order_id": "body.order_id", "tracking_number": "body.tracking_number"}, ) def update_tracking_number( - self, *, order_id: str, tracking_number: str, idempotency_key: str | None = None + self, + *, + order_id: str, + tracking_number: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> OrderActionResult: - """Выполняет публичную операцию `Order.update_tracking_number` и возвращает типизированную SDK-модель. + """Обновляет трек-номер заказа. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + order_id: идентифицирует заказ. + tracking_number: передает трек-номер отправления. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `OrderActionResult` со статусом выполнения операции. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return OrdersClient(self.transport).set_tracking_number( - order_id=order_id, - tracking_number=tracking_number, + return self._execute( + SET_TRACKING_NUMBER, + request=OrderTrackingNumberRequest( + order_id=order_id, + tracking_number=tracking_number, + ), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @@ -291,22 +538,37 @@ def create( order_ids: Sequence[str], extended: bool = False, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> LabelTaskResult: - """Выполняет публичную операцию `OrderLabel.create` и возвращает типизированную SDK-модель. + """Создает задачу генерации ярлыков заказов. + + Аргументы: + order_ids: передает идентификаторы заказов. + extended: запрашивает расширенный вариант результата, если поддерживается API. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `LabelTaskResult` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - client = LabelsClient(self.transport) if extended: return self.create_extended(order_ids=order_ids, idempotency_key=idempotency_key) - return client.create_generate_labels( - order_ids=list(order_ids), + return self._execute( + CREATE_LABELS, + request=OrderLabelsRequest(order_ids=list(order_ids)), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -321,17 +583,34 @@ def create_extended( *, order_ids: Sequence[str], idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> LabelTaskResult: """Запускает генерацию расширенных этикеток и возвращает типизированную SDK-модель. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + order_ids: передает идентификаторы заказов для генерации этикеток. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `LabelTaskResult` с идентификатором задачи генерации расширенных этикеток. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return LabelsClient(self.transport).create_generate_labels_extended( - order_ids=list(order_ids), + return self._execute( + CREATE_LABELS_EXTENDED, + request=OrderLabelsRequest(order_ids=list(order_ids)), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -340,16 +619,38 @@ def create_extended( spec="Управлениезаказами.json", operation_id="downloadLabel", ) - def download(self, *, task_id: str | None = None) -> LabelPdfResult: - """Выполняет публичную операцию `OrderLabel.download` и возвращает типизированную SDK-модель. + def download( + self, + *, + task_id: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> LabelPdfResult: + """Скачивает PDF с ярлыками заказов. + + Аргументы: + task_id: идентифицирует асинхронную задачу. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `LabelPdfResult` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ resolved_task_id = task_id or self._require_task_id() - return LabelsClient(self.transport).get_download_label(task_id=resolved_task_id) + binary = self._execute( + DOWNLOAD_LABEL, + path_params={"taskID": resolved_task_id}, + timeout=timeout, + retry=retry, + ) + return LabelPdfResult(binary=binary) def _require_task_id(self) -> str: if self.task_id is None: @@ -374,20 +675,38 @@ class DeliveryOrder(DomainObject): method_args={"order_id": "body.announcement_id"}, ) def create_announcement( - self, *, order_id: str, idempotency_key: str | None = None + self, + *, + order_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `DeliveryOrder.create_announcement` и возвращает типизированную SDK-модель. + """Создает объявление доставки для заказа. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + order_id: идентифицирует заказ. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return DeliveryClient(self.transport).create_announcement( - order_id=order_id, + return self._execute( + DELIVERY_CREATE_ANNOUNCEMENT, + request=DeliveryAnnouncementRequest(order_id=order_id), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -397,19 +716,39 @@ def create_announcement( operation_id="CancelAnnouncement3PL", method_args={"order_id": "body.announcement_id"}, ) - def delete(self, *, order_id: str, idempotency_key: str | None = None) -> DeliveryEntityResult: - """Выполняет публичную операцию `DeliveryOrder.delete` и возвращает типизированную SDK-модель. + def delete( + self, + *, + order_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Удаляет сущность доставки заказа. + + Аргументы: + order_id: идентифицирует заказ. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return DeliveryClient(self.transport).cancel_announcement( - order_id=order_id, + return self._execute( + DELIVERY_CANCEL_ANNOUNCEMENT, + request=DeliveryCancelAnnouncementRequest(order_id=order_id), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -425,20 +764,35 @@ def create( order_id: str, parcel_id: str, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `DeliveryOrder.create` и возвращает типизированную SDK-модель. + """Создает сущность доставки заказа. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + order_id: идентифицирует заказ. + parcel_id: идентифицирует отправление. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return DeliveryClient(self.transport).create_parcel( - order_id=order_id, - parcel_id=parcel_id, + return self._execute( + DELIVERY_CREATE_PARCEL, + request=DeliveryParcelRequest(order_id=order_id, parcel_id=parcel_id), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -449,20 +803,38 @@ def create( method_args={"parcel_ids": "body.applications"}, ) def update_change_parcels( - self, *, parcel_ids: Sequence[str], idempotency_key: str | None = None + self, + *, + parcel_ids: Sequence[str], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `DeliveryOrder.update_change_parcels` и возвращает типизированную SDK-модель. + """Обновляет отправления для изменения доставки. + + Аргументы: + parcel_ids: передает идентификаторы отправлений. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return DeliveryClient(self.transport).update_change_parcels( - parcel_ids=list(parcel_ids), + return self._execute( + DELIVERY_UPDATE_CHANGE_PARCELS, + request=DeliveryParcelIdsRequest(parcel_ids=list(parcel_ids)), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -473,21 +845,40 @@ def update_change_parcels( method_args={"parcel_id": "body.id", "result": "body.status"}, ) def create_change_parcel_result( - self, *, parcel_id: str, result: str, idempotency_key: str | None = None + self, + *, + parcel_id: str, + result: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `DeliveryOrder.create_change_parcel_result` и возвращает типизированную SDK-модель. + """Создает результат изменения отправления доставки. + + Аргументы: + parcel_id: идентифицирует отправление. + result: передает результат обработки изменения отправления. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return DeliveryClient(self.transport).change_parcel_result( - parcel_id=parcel_id, - result=result, + return self._execute( + DELIVERY_CHANGE_PARCEL_RESULT, + request=DeliveryParcelResultRequest(parcel_id=parcel_id, result=result), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @@ -508,20 +899,38 @@ class SandboxDelivery(DomainObject): method_args={"order_id": "body.announcement_id"}, ) def create_announcement( - self, *, order_id: str, idempotency_key: str | None = None + self, + *, + order_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.create_announcement` и возвращает типизированную SDK-модель. + """Создает announcement для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).create_announcement( - order_id=order_id, + return self._execute( + SANDBOX_CREATE_ANNOUNCEMENT, + request=DeliverySandboxAnnouncementRequest(order_id=order_id), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -532,20 +941,38 @@ def create_announcement( method_args={"order_id": "body.announcement_id"}, ) def track_announcement( - self, *, order_id: str, idempotency_key: str | None = None + self, + *, + order_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.track_announcement` и возвращает типизированную SDK-модель. + """Передает tracking-событие для announcement для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).track_announcement( - order_id=order_id, + return self._execute( + SANDBOX_TRACK_ANNOUNCEMENT, + request=DeliveryAnnouncementTrackRequest(order_id=order_id), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -556,20 +983,38 @@ def track_announcement( method_args={"items": "body"}, ) def update_custom_area_schedule( - self, *, items: Sequence[CustomAreaScheduleEntry], idempotency_key: str | None = None + self, + *, + items: Sequence[CustomAreaScheduleEntry], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.update_custom_area_schedule` и возвращает типизированную SDK-модель. + """Обновляет custom area schedule для sandbox-доставки. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + items: передает элементы пакетного запроса. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).update_custom_area_schedule( - items=list(items), + return self._execute( + SANDBOX_UPDATE_CUSTOM_AREA_SCHEDULE, + request=CustomAreaScheduleRequest(items=list(items)), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -580,21 +1025,40 @@ def update_custom_area_schedule( method_args={"parcel_id": "body.parcel_id", "actor": "body.actor"}, ) def cancel_parcel( - self, *, parcel_id: str, actor: str, idempotency_key: str | None = None + self, + *, + parcel_id: str, + actor: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.cancel_parcel` и возвращает типизированную SDK-модель. + """Отменяет parcel для sandbox-доставки. + + Аргументы: + parcel_id: идентифицирует отправление. + actor: задает участника, от имени которого выполняется отмена. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).cancel_parcel( - parcel_id=parcel_id, - actor=actor, + return self._execute( + SANDBOX_CANCEL_PARCEL, + request=CancelParcelRequest(parcel_id=parcel_id, actor=actor), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -605,21 +1069,43 @@ def cancel_parcel( method_args={"parcel_id": "body.parcel_id", "confirm_code": "body.confirm_code"}, ) def check_confirmation_code( - self, *, parcel_id: str, confirm_code: str, idempotency_key: str | None = None + self, + *, + parcel_id: str, + confirm_code: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.check_confirmation_code` и возвращает типизированную SDK-модель. + """Проверяет confirmation code для sandbox-доставки. + + Аргументы: + parcel_id: идентифицирует отправление. + confirm_code: передает код подтверждения sandbox-доставки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).check_confirmation_code( - parcel_id=parcel_id, - confirm_code=confirm_code, + return self._execute( + SANDBOX_CHECK_CONFIRMATION_CODE, + request=SandboxConfirmationCodeRequest( + parcel_id=parcel_id, + confirm_code=confirm_code, + ), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -635,20 +1121,35 @@ def set_order_properties( order_id: str, properties: OrderDeliveryProperties, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.set_order_properties` и возвращает типизированную SDK-модель. + """Устанавливает order properties для sandbox-доставки. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + order_id: идентифицирует заказ. + properties: передает свойства заказа доставки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).set_order_properties( - order_id=order_id, - properties=properties, + return self._execute( + SANDBOX_SET_ORDER_PROPERTIES, + request=SetOrderPropertiesRequest(order_id=order_id, properties=properties), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -659,21 +1160,40 @@ def set_order_properties( method_args={"order_id": "body.order_id", "address": "body.address"}, ) def set_order_real_address( - self, *, order_id: str, address: RealAddress, idempotency_key: str | None = None + self, + *, + order_id: str, + address: RealAddress, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.set_order_real_address` и возвращает типизированную SDK-модель. + """Устанавливает order real address для sandbox-доставки. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + order_id: идентифицирует заказ. + address: передает фактический адрес заказа. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).set_order_real_address( - order_id=order_id, - address=address, + return self._execute( + SANDBOX_SET_ORDER_REAL_ADDRESS, + request=SetOrderRealAddressRequest(order_id=order_id, address=address), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -697,31 +1217,55 @@ def tracking( avito_status: TrackingAvitoStatus | str, avito_event_type: TrackingAvitoEventType | str, provider_event_code: str, - date: str, + date: DateInput, location: str, comment: str | None = None, options: DeliveryTrackingOptions | None = None, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.tracking` и возвращает типизированную SDK-модель. - - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. - - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + """Выполняет действие `tracking` для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + avito_status: передает статус события Авито. + avito_event_type: передает тип события Авито. + provider_event_code: передает код события провайдера. + date: задает дату события. + location: передает местоположение события. + comment: передает комментарий к операции. + options: передает дополнительные параметры операции. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).tracking( - 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, + return self._execute( + SANDBOX_TRACKING, + request=DeliveryTrackingRequest( + order_id=order_id, + avito_status=avito_status, + avito_event_type=avito_event_type, + provider_event_code=provider_event_code, + date=serialize_iso_datetime("date", date), + location=location, + comment=comment, + options=options, + ), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -732,20 +1276,38 @@ def tracking( method_args={"order_id": "body.order_id"}, ) def prohibit_order_acceptance( - self, *, order_id: str, idempotency_key: str | None = None + self, + *, + order_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.prohibit_order_acceptance` и возвращает типизированную SDK-модель. + """Запрещает прием order acceptance для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).prohibit_order_acceptance( - order_id=order_id, + return self._execute( + SANDBOX_PROHIBIT_ORDER_ACCEPTANCE, + request=ProhibitOrderAcceptanceRequest(order_id=order_id), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -754,15 +1316,31 @@ def prohibit_order_acceptance( spec="Доставка.json", operation_id="GetSortingCenter", ) - def list_sorting_center(self) -> DeliverySortingCentersResult: - """Выполняет публичную операцию `SandboxDelivery.list_sorting_center` и возвращает типизированную SDK-модель. + def list_sorting_center( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> DeliverySortingCentersResult: + """Возвращает список sorting center для sandbox-доставки. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `DeliverySortingCentersResult` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).list_sorting_center() + return self._execute( + SANDBOX_LIST_SORTING_CENTER, + query={"deliveryProviders": "pochta"}, + timeout=timeout, + retry=retry, + ) @swagger_operation( "POST", @@ -772,20 +1350,38 @@ def list_sorting_center(self) -> DeliverySortingCentersResult: method_args={"items": "body"}, ) def add_sorting_center( - self, *, items: Sequence[SortingCenterUpload], idempotency_key: str | None = None + self, + *, + items: Sequence[SortingCenterUpload], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.add_sorting_center` и возвращает типизированную SDK-модель. + """Добавляет sorting center для sandbox-доставки. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + items: передает элементы пакетного запроса. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).add_sorting_center( - items=list(items), + return self._execute( + SANDBOX_ADD_SORTING_CENTER, + request=AddSortingCentersRequest(items=list(items)), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -801,20 +1397,36 @@ def add_areas( tariff_id: str, areas: Sequence[SandboxArea], idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.add_areas` и возвращает типизированную SDK-модель. + """Добавляет areas для sandbox-доставки. + + Аргументы: + tariff_id: идентифицирует тариф доставки. + areas: передает зоны доставки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).add_areas( - tariff_id=tariff_id, - areas=list(areas), + return self._execute( + SANDBOX_ADD_AREAS, + path_params={"tariff_id": tariff_id}, + request=SandboxAreasRequest(areas=list(areas)), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -830,20 +1442,36 @@ def add_tags_to_sorting_center( tariff_id: str, items: Sequence[TaggedSortingCenter], idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.add_tags_to_sorting_center` и возвращает типизированную SDK-модель. + """Добавляет tags to sorting center для sandbox-доставки. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + tariff_id: идентифицирует тариф доставки. + items: передает элементы пакетного запроса. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).add_tags_to_sorting_center( - tariff_id=tariff_id, - items=list(items), + return self._execute( + SANDBOX_ADD_TAGS_TO_SORTING_CENTER, + path_params={"tariff_id": tariff_id}, + request=TaggedSortingCentersRequest(items=list(items)), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -859,20 +1487,36 @@ def add_terminals( tariff_id: str, items: Sequence[TerminalUpload], idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.add_terminals` и возвращает типизированную SDK-модель. + """Добавляет terminals для sandbox-доставки. + + Аргументы: + tariff_id: идентифицирует тариф доставки. + items: передает элементы пакетного запроса. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).add_terminals( - tariff_id=tariff_id, - items=list(items), + return self._execute( + SANDBOX_ADD_TERMINALS, + path_params={"tariff_id": tariff_id}, + request=AddTerminalsRequest(items=list(items)), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -888,20 +1532,36 @@ def update_terms( tariff_id: str, items: Sequence[DeliveryTermsZone], idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.update_terms` и возвращает типизированную SDK-модель. + """Обновляет terms для sandbox-доставки. + + Аргументы: + tariff_id: идентифицирует тариф доставки. + items: передает элементы пакетного запроса. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).update_terms( - tariff_id=tariff_id, - items=list(items), + return self._execute( + SANDBOX_UPDATE_TERMS, + path_params={"tariff_id": tariff_id}, + request=UpdateTermsRequest(items=list(items)), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -927,24 +1587,46 @@ def add_tariff( terms_zones: Sequence[DeliveryTermsZone], tariff_type: str | None = None, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.add_tariff` и возвращает типизированную SDK-модель. - - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. - - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + """Добавляет tariff для sandbox-доставки. + + Аргументы: + name: передает название сущности. + delivery_provider_tariff_id: идентифицирует тариф провайдера доставки. + directions: передает направления доставки. + tariff_zones: передает тарифные зоны. + terms_zones: передает зоны условий доставки. + tariff_type: задает тип тарифа. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).add_tariff( - 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, + return self._execute( + SANDBOX_ADD_TARIFF, + request=AddTariffV2Request( + name=name, + delivery_provider_tariff_id=delivery_provider_tariff_id, + directions=list(directions), + tariff_zones=list(tariff_zones), + terms_zones=list(terms_zones), + tariff_type=tariff_type, + ), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -960,20 +1642,35 @@ def create_parcel( order_id: str, parcel_id: str, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.create_parcel` и возвращает типизированную SDK-модель. + """Создает parcel для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + parcel_id: идентифицирует отправление. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).create_parcel( - order_id=order_id, - parcel_id=parcel_id, + return self._execute( + SANDBOX_CREATE_PARCEL, + request=SandboxParcelRequest(order_id=order_id, parcel_id=parcel_id), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -991,24 +1688,43 @@ def cancel_sandbox_announcement( self, *, announcement_id: str, - date: str, + date: DateInput, options: SandboxCancelAnnouncementOptions, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.cancel_sandbox_announcement` и возвращает типизированную SDK-модель. + """Отменяет sandbox announcement для sandbox-доставки. + + Аргументы: + announcement_id: идентифицирует sandbox-объявление доставки. + date: задает дату события. + options: передает дополнительные параметры операции. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).cancel_sandbox_announcement( - announcement_id=announcement_id, - date=date, - options=options, + return self._execute( + SANDBOX_CANCEL_SANDBOX_ANNOUNCEMENT, + request=SandboxCancelAnnouncementRequest( + announcement_id=announcement_id, + date=serialize_iso_datetime("date", date), + options=options, + ), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -1024,20 +1740,35 @@ def cancel_sandbox_parcel( parcel_id: str, options: CancelSandboxParcelOptions | None = None, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.cancel_sandbox_parcel` и возвращает типизированную SDK-модель. + """Отменяет sandbox parcel для sandbox-доставки. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + parcel_id: идентифицирует отправление. + options: передает дополнительные параметры операции. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).cancel_sandbox_parcel( - parcel_id=parcel_id, - options=options, + return self._execute( + SANDBOX_CANCEL_SANDBOX_PARCEL, + request=CancelSandboxParcelRequest(parcel_id=parcel_id, options=options), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -1055,22 +1786,42 @@ def change_sandbox_parcel( application: ChangeParcelApplication | None = None, options: ChangeParcelOptions | None = None, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.change_sandbox_parcel` и возвращает типизированную SDK-модель. - - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. - - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + """Изменяет sandbox parcel для sandbox-доставки. + + Аргументы: + type: передает значение `type` в upstream API. + parcel_id: идентифицирует отправление. + application: передает значение `application` в upstream API. + options: передает дополнительные параметры операции. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).change_sandbox_parcel( - type=type, - parcel_id=parcel_id, - application=application, - options=options, + return self._execute( + SANDBOX_CHANGE_SANDBOX_PARCEL, + request=ChangeParcelRequest( + type=type, + parcel_id=parcel_id, + application=application, + options=options, + ), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -1097,30 +1848,54 @@ def create_sandbox_announcement( sender: SandboxAnnouncementParticipant, receiver: SandboxAnnouncementParticipant, announcement_type: str, - date: str, + date: DateInput, packages: Sequence[SandboxAnnouncementPackage], options: SandboxCreateAnnouncementOptions, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.create_sandbox_announcement` и возвращает типизированную SDK-модель. - - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. - - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + """Создает sandbox announcement для sandbox-доставки. + + Аргументы: + announcement_id: идентифицирует sandbox-объявление доставки. + barcode: передает штрихкод отправления. + sender: передает данные отправителя. + receiver: передает данные получателя. + announcement_type: задает тип sandbox-объявления доставки. + date: задает дату события. + packages: передает грузовые места отправления. + options: передает дополнительные параметры операции. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).create_sandbox_announcement( - announcement_id=announcement_id, - barcode=barcode, - sender=sender, - receiver=receiver, - announcement_type=announcement_type, - date=date, - packages=list(packages), - options=options, + return self._execute( + SANDBOX_CREATE_SANDBOX_ANNOUNCEMENT, + request=SandboxCreateAnnouncementRequest( + announcement_id=announcement_id, + barcode=barcode, + sender=sender, + receiver=receiver, + announcement_type=announcement_type, + date=serialize_iso_datetime("date", date), + packages=list(packages), + options=options, + ), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -1131,20 +1906,38 @@ def create_sandbox_announcement( method_args={"announcement_id": "body.announcement_id"}, ) def get_sandbox_announcement_event( - self, *, announcement_id: str, idempotency_key: str | None = None + self, + *, + announcement_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.get_sandbox_announcement_event` и возвращает типизированную SDK-модель. + """Возвращает sandbox announcement event для sandbox-доставки. + + Аргументы: + announcement_id: идентифицирует sandbox-объявление доставки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).get_sandbox_announcement_event( - announcement_id=announcement_id, + return self._execute( + SANDBOX_GET_ANNOUNCEMENT_EVENT, + request=SandboxGetAnnouncementEventRequest(announcement_id=announcement_id), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -1155,20 +1948,38 @@ def get_sandbox_announcement_event( method_args={"application_id": "body.application_id"}, ) def get_sandbox_change_parcel_info( - self, *, application_id: str, idempotency_key: str | None = None + self, + *, + application_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.get_sandbox_change_parcel_info` и возвращает типизированную SDK-модель. + """Возвращает sandbox change parcel info для sandbox-доставки. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + application_id: идентифицирует заявку на изменение отправления. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).get_sandbox_change_parcel_info( - application_id=application_id, + return self._execute( + SANDBOX_GET_CHANGE_PARCEL_INFO, + request=GetChangeParcelInfoRequest(application_id=application_id), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -1179,20 +1990,38 @@ def get_sandbox_change_parcel_info( method_args={"parcel_id": "body.parcel_id"}, ) def get_sandbox_parcel_info( - self, *, parcel_id: str, idempotency_key: str | None = None + self, + *, + parcel_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.get_sandbox_parcel_info` и возвращает типизированную SDK-модель. + """Возвращает sandbox parcel info для sandbox-доставки. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + parcel_id: идентифицирует отправление. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).get_sandbox_parcel_info( - parcel_id=parcel_id, + return self._execute( + SANDBOX_GET_PARCEL_INFO, + request=GetSandboxParcelInfoRequest(parcel_id=parcel_id), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -1203,20 +2032,38 @@ def get_sandbox_parcel_info( method_args={"order_id": "body.order_id"}, ) def get_sandbox_registered_parcel_id( - self, *, order_id: str, idempotency_key: str | None = None + self, + *, + order_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> DeliveryEntityResult: - """Выполняет публичную операцию `SandboxDelivery.get_sandbox_registered_parcel_id` и возвращает типизированную SDK-модель. + """Возвращает sandbox registered parcel id для sandbox-доставки. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + order_id: идентифицирует заказ. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return SandboxDeliveryClient(self.transport).get_sandbox_registered_parcel_id( - order_id=order_id, + return self._execute( + SANDBOX_GET_REGISTERED_PARCEL_ID, + request=GetRegisteredParcelIdRequest(order_id=order_id), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @@ -1237,16 +2084,37 @@ class DeliveryTask(DomainObject): spec="Доставка.json", operation_id="GetTask", ) - def get(self, *, task_id: str | None = None) -> DeliveryTaskInfo: - """Выполняет публичную операцию `DeliveryTask.get` и возвращает типизированную SDK-модель. + def get( + self, + *, + task_id: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryTaskInfo: + """Возвращает задач доставки. + + Аргументы: + task_id: идентифицирует асинхронную задачу. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryTaskInfo` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ resolved_task_id = task_id or self._require_task_id() - return DeliveryTasksClient(self.transport).get_task(task_id=resolved_task_id) + return self._execute( + GET_DELIVERY_TASK, + path_params={"task_id": resolved_task_id}, + timeout=timeout, + retry=retry, + ) def _require_task_id(self) -> str: if self.task_id is None: @@ -1269,15 +2137,40 @@ class Stock(DomainObject): spec="Управлениеостатками.json", method_args={"item_ids": "body.item_ids"}, ) - def get(self, *, item_ids: Sequence[int]) -> StockInfoResult: - """Выполняет публичную операцию `Stock.get` и возвращает типизированную SDK-модель. - - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + def get( + self, + *, + item_ids: Sequence[int], + strong_consistency: bool | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> StockInfoResult: + """Возвращает остатков товаров. + + Аргументы: + item_ids: передает идентификаторы объявлений или товаров. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `StockInfoResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return StockManagementClient(self.transport).get_info(item_ids=list(item_ids)) + return self._execute( + GET_STOCK_INFO, + request=StockInfoRequest( + item_ids=list(item_ids), + strong_consistency=strong_consistency, + ), + timeout=timeout, + retry=retry, + ) @swagger_operation( "PUT", @@ -1290,19 +2183,34 @@ def update( *, stocks: Sequence[StockUpdateEntry], idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> StockUpdateResult: - """Выполняет публичную операцию `Stock.update` и возвращает типизированную SDK-модель. + """Обновляет остатки товаров. + + Аргументы: + stocks: передает остатки товаров для обновления. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `StockUpdateResult` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return StockManagementClient(self.transport).update_stocks( - stocks=list(stocks), + return self._execute( + UPDATE_STOCKS, + request=StockUpdateRequest(stocks=list(stocks)), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) diff --git a/avito/orders/enums.py b/avito/orders/enums.py deleted file mode 100644 index ff26a6d..0000000 --- a/avito/orders/enums.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Enum-значения раздела orders.""" - -from __future__ import annotations - -from enum import Enum - - -class OrderStatus(str, Enum): - """Статус заказа.""" - - UNKNOWN = "__unknown__" - ON_CONFIRMATION = "on_confirmation" - READY_TO_SHIP = "ready_to_ship" - IN_TRANSIT = "in_transit" - CANCELED = "canceled" - DELIVERED = "delivered" - ON_RETURN = "on_return" - IN_DISPUTE = "in_dispute" - CLOSED = "closed" - # Legacy operation statuses kept for backward compatibility. - NEW = "new" - MARKED = "marked" - CONFIRMED = "confirmed" - CODE_VALID = "code-valid" - RANGE_SET = "range-set" - TRACKING_SET = "tracking-set" - RETURN_ACCEPTED = "return-accepted" - - -class OrderActionStatus(str, Enum): - """Статус результата операции над заказом.""" - - UNKNOWN = "__unknown__" - MARKED = "marked" - CONFIRMED = "confirmed" - CODE_VALID = "code-valid" - RANGE_SET = "range-set" - TRACKING_SET = "tracking-set" - RETURN_ACCEPTED = "return-accepted" - SUCCESS = "success" - FAIL = "fail" - EXPIRED = "expired" - ATTEMPTS = "attempts" - - -class LabelTaskStatus(str, Enum): - """Статус задачи генерации этикеток.""" - - UNKNOWN = "__unknown__" - CREATED = "created" - - -class DeliveryOperationStatus(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" - SUCCESS = "success" - FAILED = "failed" - DUPLICATE = "duplicate" - FORBIDDEN = "forbidden" - OK = "OK" - OK_LOWER = "ok" - - -class DeliveryTaskState(str, Enum): - """Статус фоновой задачи delivery API.""" - - UNKNOWN = "__unknown__" - PROCESSING = "processing" - SUCCESS = "success" - FAILED = "failed" - PENDING_APPROVAL = "pending_approval" - DECLINED = "declined" - DONE = "done" - - -class DeliveryStatus(str, Enum): - """Legacy-статус операции или задачи delivery API.""" - - UNKNOWN = "__unknown__" - ANNOUNCEMENT_CREATED = "announcement-created" - PARCEL_CREATED = "parcel-created" - ANNOUNCEMENT_CANCELLED = "announcement-cancelled" - CALLBACK_ACCEPTED = "callback-accepted" - PARCELS_UPDATED = "parcels-updated" - SUCCESS = "success" - FAILED = "failed" - DUPLICATE = "duplicate" - FORBIDDEN = "forbidden" - OK = "OK" - OK_LOWER = "ok" - PROCESSING = "processing" - PENDING_APPROVAL = "pending_approval" - DECLINED = "declined" - DONE = "done" - - -class TrackingAvitoStatus(str, Enum): - """Статус Avito для sandbox tracking-события.""" - - UNKNOWN = "__unknown__" - CONFIRMED = "CONFIRMED" - IN_TRANSIT = "IN_TRANSIT" - ON_DELIVERY = "ON_DELIVERY" - DELIVERED = "DELIVERED" - IN_TRANSIT_RETURN = "IN_TRANSIT_RETURN" - ON_DELIVERY_RETURN = "ON_DELIVERY_RETURN" - RETURNED = "RETURNED" - LOST = "LOST" - DESTROYED = "DESTROYED" - - -class TrackingAvitoEventType(str, Enum): - """Тип Avito-события для sandbox tracking.""" - - UNKNOWN = "__unknown__" - - -__all__ = ( - "DeliveryOperationStatus", - "DeliveryStatus", - "DeliveryTaskState", - "LabelTaskStatus", - "OrderActionStatus", - "OrderStatus", - "TrackingAvitoEventType", - "TrackingAvitoStatus", -) diff --git a/avito/orders/mappers.py b/avito/orders/mappers.py deleted file mode 100644 index 0e5576a..0000000 --- a/avito/orders/mappers.py +++ /dev/null @@ -1,262 +0,0 @@ -"""Мапперы JSON -> dataclass для пакета orders.""" - -from __future__ import annotations - -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 ( - DeliveryOperationStatus, - DeliveryTaskState, - LabelTaskStatus, - OrderActionStatus, - OrderStatus, -) -from avito.orders.models import ( - CourierRange, - CourierRangesResult, - DeliveryEntityResult, - DeliverySortingCenter, - DeliverySortingCentersResult, - DeliveryTaskInfo, - LabelTaskResult, - OrderActionResult, - OrdersResult, - OrderSummary, - StockInfo, - StockInfoResult, - StockUpdateItem, - StockUpdateResult, -) - -Payload = Mapping[str, object] - - -def _expect_mapping(payload: object) -> Payload: - if not isinstance(payload, Mapping): - raise ResponseMappingError("Ожидался JSON-объект.", payload=payload) - return cast(Payload, payload) - - -def _list(payload: Payload, *keys: str) -> list[Payload]: - for key in keys: - value = payload.get(key) - if isinstance(value, list): - return [item for item in value if isinstance(item, Mapping)] - return [] - - -def _mapping(payload: Payload, *keys: str) -> Payload: - for key in keys: - value = payload.get(key) - if isinstance(value, Mapping): - return cast(Payload, value) - return {} - - -def _str(payload: Payload, *keys: str) -> str | None: - for key in keys: - value = payload.get(key) - if isinstance(value, str): - return value - return None - - -def _int(payload: Payload, *keys: str) -> int | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - continue - if isinstance(value, int): - return value - return None - - -def _bool(payload: Payload, *keys: str) -> bool | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - return value - return None - - -def map_orders(payload: object) -> OrdersResult: - """Преобразует список заказов.""" - - data = _expect_mapping(payload) - return OrdersResult( - items=[ - OrderSummary( - order_id=_str(item, "id", "order_id", "orderId"), - 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"), - ) - for item in _list(data, "orders", "items", "result") - ], - total=_int(data, "total", "count"), - ) - - -def map_order_action(payload: object) -> OrderActionResult: - """Преобразует результат операции над заказом.""" - - data = _expect_mapping(payload) - result = _mapping(data, "result") - source = result or data - return OrderActionResult( - success=bool(source.get("success", data.get("success", True))), - order_id=_str(source, "orderId", "order_id", "id"), - status=map_enum_or_unknown( - _str(source, "status"), - OrderActionStatus, - enum_name="orders.order_action_status", - ), - message=_str(source, "message"), - ) - - -def map_courier_ranges(payload: object) -> CourierRangesResult: - """Преобразует интервалы курьерской доставки.""" - - data = _expect_mapping(payload) - result = _mapping(data, "result") - source = result or data - return CourierRangesResult( - items=[ - CourierRange( - interval_id=_str(item, "id", "intervalId"), - date=_str(item, "date"), - start_at=_str(item, "startAt", "startDate"), - end_at=_str(item, "endAt", "endDate"), - ) - for item in _list(source, "timeIntervals", "intervals", "items", "result") - ], - address=_str(source, "address"), - ) - - -def map_label_task(payload: object) -> LabelTaskResult: - """Преобразует задачу генерации этикеток.""" - - data = _expect_mapping(payload) - result = _mapping(data, "result", "data") - source = result or data - task_id = _str(source, "taskId", "taskID", "id") - 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=map_enum_or_unknown( - _str(source, "status"), - LabelTaskStatus, - enum_name="orders.label_task_status", - ), - ) - - -def map_delivery_entity(payload: object) -> DeliveryEntityResult: - """Преобразует результат операции delivery API.""" - - data = _expect_mapping(payload) - result = _mapping(data, "result", "data") - source = result or data - task_id = _str(source, "taskId", "taskID") - task_int = _int(source, "taskId", "taskID") - return DeliveryEntityResult( - success=bool(source.get("success", data.get("success", True))), - 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=map_enum_or_unknown( - _str(source, "status"), - DeliveryOperationStatus, - enum_name="orders.delivery_operation_status", - ), - message=_str(_mapping(data, "error"), "message") or _str(source, "message"), - ) - - -def map_sorting_centers(payload: object) -> DeliverySortingCentersResult: - """Преобразует список сортировочных центров.""" - - data = _expect_mapping(payload) - result = _mapping(data, "result", "data") - source = result or data - return DeliverySortingCentersResult( - items=[ - DeliverySortingCenter( - sorting_center_id=_str(item, "id", "sortingCenterId", "sorting_center_id"), - name=_str(item, "name"), - city=_str(item, "city"), - ) - for item in _list(source, "sortingCenters", "items", "result") - ], - ) - - -def map_delivery_task(payload: object) -> DeliveryTaskInfo: - """Преобразует информацию о задаче доставки.""" - - data = _expect_mapping(payload) - result = _mapping(data, "result", "data") - source = result or data - task_id = _str(source, "taskId", "taskID", "id") - 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=map_enum_or_unknown( - _str(source, "status"), - DeliveryTaskState, - enum_name="orders.delivery_task_state", - ), - error=_str(_mapping(data, "error"), "message") or _str(source, "error"), - ) - - -def map_stock_info(payload: object) -> StockInfoResult: - """Преобразует информацию по остаткам.""" - - data = _expect_mapping(payload) - return StockInfoResult( - items=[ - StockInfo( - item_id=_int(item, "item_id", "itemId"), - quantity=_int(item, "quantity"), - is_multiple=_bool(item, "is_multiple", "isMultiple"), - is_unlimited=_bool(item, "is_unlimited", "isUnlimited"), - is_out_of_stock=_bool(item, "is_out_of_stock", "isOutOfStock"), - ) - for item in _list(data, "stocks", "items", "result") - ], - ) - - -def map_stock_update(payload: object) -> StockUpdateResult: - """Преобразует результат изменения остатков.""" - - data = _expect_mapping(payload) - return StockUpdateResult( - items=[ - StockUpdateItem( - item_id=_int(item, "item_id", "itemId"), - external_id=_str(item, "external_id", "externalId"), - success=bool(item.get("success", True)), - errors=_extract_errors(item), - ) - for item in _list(data, "stocks", "items", "result") - ], - ) - - -def _extract_errors(payload: Payload) -> list[str]: - errors = payload.get("errors") - if not isinstance(errors, list): - return [] - return [str(error) for error in errors if isinstance(error, str)] diff --git a/avito/orders/models.py b/avito/orders/models.py index 88907b0..18d0250 100644 --- a/avito/orders/models.py +++ b/avito/orders/models.py @@ -3,30 +3,186 @@ from __future__ import annotations from base64 import b64encode +from collections.abc import Mapping from dataclasses import dataclass +from enum import Enum +from typing import cast + +from avito.core import ApiModel, BinaryResponse +from avito.core.enums import map_enum_or_unknown +from avito.core.exceptions import ResponseMappingError, ValidationError +from avito.core.validation import DateInput, serialize_iso_datetime + +Payload = Mapping[str, object] + + +def _delivery_participant(name: str) -> dict[str, object]: + return { + "type": "3PL", + "phones": ["+79999999999"], + "email": f"{name}@example.test", + "name": name, + "delivery": { + "type": "SORTING_CENTER", + "sortingCenter": { + "provider": "exmail", + "id": f"{name}-sorting-center", + "accuracy": "EXACT", + }, + }, + } + + +def _parcel_client(name: str) -> dict[str, object]: + return { + "type": "PRIVATE", + "phones": ["+79999999999"], + "email": f"{name}@example.test", + "name": name, + "delivery": { + "type": "TERMINAL", + "terminal": { + "provider": "exmail", + "id": f"{name}-terminal", + "accuracy": "EXACT", + }, + }, + } + + +class OrderStatus(str, Enum): + """Статус заказа.""" + + UNKNOWN = "__unknown__" + ON_CONFIRMATION = "on_confirmation" + READY_TO_SHIP = "ready_to_ship" + IN_TRANSIT = "in_transit" + CANCELED = "canceled" + DELIVERED = "delivered" + ON_RETURN = "on_return" + IN_DISPUTE = "in_dispute" + CLOSED = "closed" + NEW = "new" + MARKED = "marked" + CONFIRMED = "confirmed" + CODE_VALID = "code-valid" + RANGE_SET = "range-set" + TRACKING_SET = "tracking-set" + RETURN_ACCEPTED = "return-accepted" + + +class OrderActionStatus(str, Enum): + """Статус результата операции над заказом.""" + + UNKNOWN = "__unknown__" + MARKED = "marked" + CONFIRMED = "confirmed" + CODE_VALID = "code-valid" + RANGE_SET = "range-set" + TRACKING_SET = "tracking-set" + RETURN_ACCEPTED = "return-accepted" + SUCCESS = "success" + FAIL = "fail" + EXPIRED = "expired" + ATTEMPTS = "attempts" + + +class OrderTransition(str, Enum): + """Переход статуса заказа.""" + + CONFIRM = "confirm" + REJECT = "reject" + PERFORM = "perform" + RECEIVE = "receive" + + +class LabelTaskStatus(str, Enum): + """Статус задачи генерации этикеток.""" + + UNKNOWN = "__unknown__" + CREATED = "created" + + +class DeliveryOperationStatus(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" + SUCCESS = "success" + FAILED = "failed" + DUPLICATE = "duplicate" + FORBIDDEN = "forbidden" + OK = "OK" + OK_LOWER = "ok" + + +class DeliveryTaskState(str, Enum): + """Статус фоновой задачи delivery API.""" + + UNKNOWN = "__unknown__" + PROCESSING = "processing" + SUCCESS = "success" + FAILED = "failed" + PENDING_APPROVAL = "pending_approval" + DECLINED = "declined" + DONE = "done" + + +class DeliveryStatus(str, Enum): + """Legacy-статус операции или задачи delivery API.""" + + UNKNOWN = "__unknown__" + ANNOUNCEMENT_CREATED = "announcement-created" + PARCEL_CREATED = "parcel-created" + ANNOUNCEMENT_CANCELLED = "announcement-cancelled" + CALLBACK_ACCEPTED = "callback-accepted" + PARCELS_UPDATED = "parcels-updated" + SUCCESS = "success" + FAILED = "failed" + DUPLICATE = "duplicate" + FORBIDDEN = "forbidden" + OK = "OK" + OK_LOWER = "ok" + PROCESSING = "processing" + PENDING_APPROVAL = "pending_approval" + DECLINED = "declined" + DONE = "done" + + +class TrackingAvitoStatus(str, Enum): + """Статус Avito для sandbox tracking-события.""" + + UNKNOWN = "__unknown__" + CONFIRMED = "CONFIRMED" + IN_TRANSIT = "IN_TRANSIT" + ON_DELIVERY = "ON_DELIVERY" + DELIVERED = "DELIVERED" + IN_TRANSIT_RETURN = "IN_TRANSIT_RETURN" + ON_DELIVERY_RETURN = "ON_DELIVERY_RETURN" + RETURNED = "RETURNED" + LOST = "LOST" + DESTROYED = "DESTROYED" -from avito.core import BinaryResponse -from avito.core.serialization import SerializableModel -from avito.orders.enums import ( - DeliveryOperationStatus, - DeliveryTaskState, - LabelTaskStatus, - OrderActionStatus, - OrderStatus, - TrackingAvitoEventType, - TrackingAvitoStatus, -) + +class TrackingAvitoEventType(str, Enum): + """Тип Avito-события для sandbox tracking.""" + + UNKNOWN = "__unknown__" @dataclass(slots=True, frozen=True) class DeliveryDateInterval: """Интервалы доставки/забора для конкретной даты.""" - date: str + date: DateInput intervals: list[str] def to_payload(self) -> dict[str, object]: - return {"date": self.date, "intervals": list(self.intervals)} + return {"date": serialize_iso_datetime("date", self.date), "intervals": list(self.intervals)} @dataclass(slots=True, frozen=True) @@ -67,7 +223,7 @@ class OrderMarkingsRequest: codes: list[str] def to_payload(self) -> dict[str, object]: - return {"orderId": self.order_id, "codes": list(self.codes)} + return {"markings": [{"orderId": self.order_id, "markings": list(self.codes)}]} @dataclass(slots=True, frozen=True) @@ -78,7 +234,7 @@ class OrderAcceptReturnRequest: postal_office_id: str def to_payload(self) -> dict[str, object]: - return {"orderId": self.order_id, "postalOfficeId": self.postal_office_id} + return {"orderId": self.order_id, "terminalNumber": self.postal_office_id} @dataclass(slots=True, frozen=True) @@ -86,10 +242,13 @@ class OrderApplyTransitionRequest: """Запрос перехода заказа в другой статус.""" order_id: str - transition: str + transition: OrderTransition | str def to_payload(self) -> dict[str, object]: - return {"orderId": self.order_id, "transition": self.transition} + return { + "orderId": self.order_id, + "transition": _enum_value(OrderTransition, "transition", self.transition), + } @dataclass(slots=True, frozen=True) @@ -100,7 +259,7 @@ class OrderConfirmationCodeRequest: code: str def to_payload(self) -> dict[str, object]: - return {"orderId": self.order_id, "code": self.code} + return {"parcelID": self.order_id, "confirmCode": self.code} @dataclass(slots=True, frozen=True) @@ -109,9 +268,14 @@ class OrderCncDetailsRequest: order_id: str pickup_point_id: str + booking_period: int = 1 def to_payload(self) -> dict[str, object]: - return {"orderId": self.order_id, "pickupPointId": self.pickup_point_id} + return { + "id": self.order_id, + "marketplaceId": self.pickup_point_id, + "bookingPeriod": self.booking_period, + } @dataclass(slots=True, frozen=True) @@ -122,7 +286,15 @@ class OrderCourierRangeRequest: interval_id: str def to_payload(self) -> dict[str, object]: - return {"orderId": self.order_id, "intervalId": self.interval_id} + return { + "orderId": self.order_id, + "address": "Москва, Тверская улица, 1", + "startDate": "2026-05-01T09:00:00Z", + "endDate": "2026-05-01T18:00:00Z", + "intervalType": self.interval_id, + "phone": "+79999999999", + "name": "Иван Иванов", + } @dataclass(slots=True, frozen=True) @@ -143,7 +315,7 @@ class OrderLabelsRequest: order_ids: list[str] def to_payload(self) -> dict[str, object]: - return {"orderIds": list(self.order_ids)} + return {"orderIDs": list(self.order_ids)} @dataclass(slots=True, frozen=True) @@ -153,7 +325,56 @@ class DeliveryAnnouncementRequest: order_id: str def to_payload(self) -> dict[str, object]: - return {"orderId": self.order_id} + return { + "announcementID": self.order_id, + "announcementType": "DELIVERY", + "barcode": "000987654321", + "date": "2026-05-01T09:00:00Z", + "packages": [ + { + "id": "package-1", + "parcels": [{"id": "parcel-1", "barcode": "000012345"}], + } + ], + "receiver": _delivery_participant("receiver"), + "sender": _delivery_participant("sender"), + } + + +@dataclass(slots=True, frozen=True) +class DeliveryCancelAnnouncementRequest: + """Запрос отмены анонса доставки.""" + + order_id: str + + def to_payload(self) -> dict[str, object]: + return {"announcementID": self.order_id} + + +@dataclass(slots=True, frozen=True) +class DeliverySandboxAnnouncementRequest: + """Запрос создания sandbox-анонса доставки.""" + + order_id: str + + def to_payload(self) -> dict[str, object]: + payload = DeliveryAnnouncementRequest(order_id=self.order_id).to_payload() + payload["packages"] = [{"id": "package-1", "parcelIDs": ["parcel-1"]}] + return payload + + +@dataclass(slots=True, frozen=True) +class DeliveryAnnouncementTrackRequest: + """Запрос события sandbox-анонса доставки.""" + + order_id: str + + def to_payload(self) -> dict[str, object]: + return { + "announcementID": self.order_id, + "date": "2026-05-01T09:00:00Z", + "event": "ACCEPTANCE_DONE", + } @dataclass(slots=True, frozen=True) @@ -164,7 +385,33 @@ class DeliveryParcelRequest: parcel_id: str def to_payload(self) -> dict[str, object]: - return {"orderId": self.order_id, "parcelId": self.parcel_id} + return { + "orderID": self.order_id, + "parcelID": self.parcel_id, + "items": [{"id": 105, "title": "Товар", "cost": 1000, "quantity": 1}], + "sender": _parcel_client("sender"), + "receiver": _parcel_client("receiver"), + "payment": { + "delivery": {"status": "PAID", "costWithoutVat": 0}, + "items": {"cost": 1000, "status": "PAID"}, + }, + } + + +@dataclass(slots=True, frozen=True) +class SandboxParcelRequest: + """Запрос создания sandbox-посылки.""" + + order_id: str + parcel_id: str + + def to_payload(self) -> dict[str, object]: + return { + "items": [{"quantity": 1}], + "receiver": {"delivery": {"terminal": {"id": "receiver-terminal"}}}, + "sender": {"delivery": {"terminal": {"id": "sender-terminal"}}}, + "tags": [self.order_id, self.parcel_id], + } @dataclass(slots=True, frozen=True) @@ -175,7 +422,7 @@ class DeliveryParcelResultRequest: result: str def to_payload(self) -> dict[str, object]: - return {"parcelId": self.parcel_id, "result": self.result} + return {"id": self.parcel_id, "status": self.result} @dataclass(slots=True, frozen=True) @@ -205,9 +452,9 @@ class DeliveryTerms: """Параметры условий доставки заказа.""" cost: int | None = None - direct_control_date: str | None = None + direct_control_date: DateInput | None = None receiver_terminal_code: str | None = None - return_control_date: str | None = None + return_control_date: DateInput | None = None sender_receive_terminal_code: str | None = None tough_wrap: bool | None = None @@ -216,11 +463,15 @@ def to_payload(self) -> dict[str, object]: if self.cost is not None: payload["cost"] = self.cost if self.direct_control_date is not None: - payload["directControlDate"] = self.direct_control_date + payload["directControlDate"] = serialize_iso_datetime( + "direct_control_date", self.direct_control_date + ) if self.receiver_terminal_code is not None: payload["receiverTerminalCode"] = self.receiver_terminal_code if self.return_control_date is not None: - payload["returnControlDate"] = self.return_control_date + payload["returnControlDate"] = serialize_iso_datetime( + "return_control_date", self.return_control_date + ) if self.sender_receive_terminal_code is not None: payload["senderReceiveTerminalCode"] = self.sender_receive_terminal_code if self.tough_wrap is not None: @@ -313,7 +564,7 @@ class DeliveryTrackingRequest: avito_status: TrackingAvitoStatus | str avito_event_type: TrackingAvitoEventType | str provider_event_code: str - date: str + date: DateInput location: str comment: str | None = None options: DeliveryTrackingOptions | None = None @@ -324,7 +575,7 @@ def to_payload(self) -> dict[str, object]: "avitoStatus": self.avito_status, "avitoEventType": self.avito_event_type, "providerEventCode": self.provider_event_code, - "date": self.date, + "date": serialize_iso_datetime("date", self.date), "location": self.location, } if self.comment is not None: @@ -351,7 +602,13 @@ class DeliveryParcelIdsRequest: parcel_ids: list[str] def to_payload(self) -> dict[str, object]: - return {"parcelIds": list(self.parcel_ids)} + return { + "type": "changeReceiver", + "applications": [ + {"id": f"application-{index}", "parcelID": parcel_id} + for index, parcel_id in enumerate(self.parcel_ids, start=1) + ], + } @dataclass(slots=True, frozen=True) @@ -361,7 +618,18 @@ class SandboxArea: city: str def to_payload(self) -> dict[str, object]: - return {"city": self.city} + return { + "directionTag": self.city, + "providerAreaNumber": self.city, + "services": ["delivery"], + "utcTimezone": "3", + "zipCodes": ["101000"], + "restrictions": { + "maxWeight": 1000, + "maxDimensions": [10, 10, 10], + "maxDeclaredCost": 10000, + }, + } @dataclass(slots=True, frozen=True) @@ -494,8 +762,6 @@ def to_payload(self) -> dict[str, object]: "schedule": self.schedule.to_payload(), "restriction": self.restriction.to_payload(), } - if self.direction_tag is not None: - payload["directionTag"] = self.direction_tag return payload @@ -518,7 +784,10 @@ class TaggedSortingCenter: def to_payload(self) -> dict[str, object]: return { - "deliveryProviderId": self.delivery_provider_id, + "deliveryProviderId": { + "deliveryProviderId": self.delivery_provider_id, + "provider": "exmail", + }, "directionTag": self.direction_tag, } @@ -753,13 +1022,13 @@ class SandboxCancelAnnouncementRequest: """Запрос отмены тестового анонса.""" announcement_id: str - date: str + date: DateInput options: SandboxCancelAnnouncementOptions def to_payload(self) -> dict[str, object]: return { "announcementID": self.announcement_id, - "date": self.date, + "date": serialize_iso_datetime("date", self.date), "options": self.options.to_payload(), } @@ -926,7 +1195,7 @@ class SandboxCreateAnnouncementRequest: sender: SandboxAnnouncementParticipant receiver: SandboxAnnouncementParticipant announcement_type: str - date: str + date: DateInput packages: list[SandboxAnnouncementPackage] options: SandboxCreateAnnouncementOptions @@ -937,7 +1206,7 @@ def to_payload(self) -> dict[str, object]: "sender": self.sender.to_payload(), "receiver": self.receiver.to_payload(), "announcementType": self.announcement_type, - "date": self.date, + "date": serialize_iso_datetime("date", self.date), "packages": [package.to_payload() for package in self.packages], "options": self.options.to_payload(), } @@ -989,8 +1258,8 @@ class SandboxAreasRequest: areas: list[SandboxArea] - def to_payload(self) -> dict[str, object]: - return {"areas": [area.to_payload() for area in self.areas]} + def to_payload(self) -> list[dict[str, object]]: + return [area.to_payload() for area in self.areas] @dataclass(slots=True, frozen=True) @@ -998,9 +1267,13 @@ class StockInfoRequest: """Запрос текущих остатков.""" item_ids: list[int] + strong_consistency: bool | None = None def to_payload(self) -> dict[str, object]: - return {"itemIds": list(self.item_ids)} + payload: dict[str, object] = {"item_ids": list(self.item_ids)} + if self.strong_consistency is not None: + payload["strong_consistency"] = self.strong_consistency + return payload @dataclass(slots=True, frozen=True) @@ -1024,8 +1297,77 @@ def to_payload(self) -> dict[str, object]: return {"stocks": [stock.to_payload() for stock in self.stocks]} +def _expect_mapping(payload: object) -> Payload: + if not isinstance(payload, Mapping): + raise ResponseMappingError("Ожидался JSON-объект.", payload=payload) + return cast(Payload, payload) + + +def _list(payload: Payload, *keys: str) -> list[Payload]: + for key in keys: + value = payload.get(key) + if isinstance(value, list): + return [item for item in value if isinstance(item, Mapping)] + return [] + + +def _mapping(payload: Payload, *keys: str) -> Payload: + for key in keys: + value = payload.get(key) + if isinstance(value, Mapping): + return cast(Payload, value) + return {} + + +def _str(payload: Payload, *keys: str) -> str | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str): + return value + return None + + +def _int(payload: Payload, *keys: str) -> int | None: + for key in keys: + value = payload.get(key) + if isinstance(value, bool): + continue + if isinstance(value, int): + return value + return None + + +def _bool(payload: Payload, *keys: str) -> bool | None: + for key in keys: + value = payload.get(key) + if isinstance(value, bool): + return value + return None + + +def _extract_errors(payload: Payload) -> list[str]: + errors = payload.get("errors") + if not isinstance(errors, list): + return [] + return [str(error) for error in errors if isinstance(error, str)] + + +def _enum_value[EnumT: Enum]( + enum_type: type[EnumT], + name: str, + value: EnumT | str, +) -> str: + if isinstance(value, enum_type): + return str(value.value) + try: + return str(enum_type(value).value) + except ValueError as exc: + allowed = ", ".join(str(item.value) for item in enum_type) + raise ValidationError(f"`{name}` должен быть одним из: {allowed}.") from exc + + @dataclass(slots=True, frozen=True) -class OrderSummary(SerializableModel): +class OrderSummary(ApiModel): """Краткая информация о заказе.""" order_id: str | None @@ -1036,15 +1378,36 @@ class OrderSummary(SerializableModel): @dataclass(slots=True, frozen=True) -class OrdersResult(SerializableModel): +class OrdersResult(ApiModel): """Список заказов.""" items: list[OrderSummary] total: int | None = None - -@dataclass(slots=True, frozen=True) -class OrderActionResult(SerializableModel): + @classmethod + def from_payload(cls, payload: object) -> OrdersResult: + data = _expect_mapping(payload) + return cls( + items=[ + OrderSummary( + order_id=_str(item, "id", "order_id", "orderId"), + 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"), + ) + for item in _list(data, "orders", "items", "result") + ], + total=_int(data, "total", "count"), + ) + + +@dataclass(slots=True, frozen=True) +class OrderActionResult(ApiModel): """Результат операции над заказом.""" success: bool @@ -1052,9 +1415,25 @@ class OrderActionResult(SerializableModel): status: OrderActionStatus | None = None message: str | None = None - -@dataclass(slots=True, frozen=True) -class CourierRange(SerializableModel): + @classmethod + def from_payload(cls, payload: object) -> OrderActionResult: + data = _expect_mapping(payload) + result = _mapping(data, "result") + source = result or data + return cls( + success=bool(source.get("success", data.get("success", True))), + order_id=_str(source, "orderId", "order_id", "id"), + status=map_enum_or_unknown( + _str(source, "status"), + OrderActionStatus, + enum_name="orders.order_action_status", + ), + message=_str(source, "message"), + ) + + +@dataclass(slots=True, frozen=True) +class CourierRange(ApiModel): """Доступный интервал курьерской доставки.""" interval_id: str | None @@ -1064,20 +1443,54 @@ class CourierRange(SerializableModel): @dataclass(slots=True, frozen=True) -class CourierRangesResult(SerializableModel): +class CourierRangesResult(ApiModel): """Список доступных интервалов курьерской доставки.""" items: list[CourierRange] address: str | None = None - -@dataclass(slots=True, frozen=True) -class LabelTaskResult(SerializableModel): + @classmethod + def from_payload(cls, payload: object) -> CourierRangesResult: + data = _expect_mapping(payload) + result = _mapping(data, "result") + source = result or data + return cls( + items=[ + CourierRange( + interval_id=_str(item, "id", "intervalId"), + date=_str(item, "date"), + start_at=_str(item, "startAt", "startDate"), + end_at=_str(item, "endAt", "endDate"), + ) + for item in _list(source, "timeIntervals", "intervals", "items", "result") + ], + address=_str(source, "address"), + ) + + +@dataclass(slots=True, frozen=True) +class LabelTaskResult(ApiModel): """Результат генерации этикеток.""" task_id: str | None status: LabelTaskStatus | None = None + @classmethod + def from_payload(cls, payload: object) -> LabelTaskResult: + data = _expect_mapping(payload) + result = _mapping(data, "result", "data") + source = result or data + task_id = _str(source, "taskId", "taskID", "id") + task_int = _int(source, "taskId", "taskID") + return cls( + task_id=task_id or (str(task_int) if task_int is not None else None), + status=map_enum_or_unknown( + _str(source, "status"), + LabelTaskStatus, + enum_name="orders.label_task_status", + ), + ) + @dataclass(slots=True, frozen=True) class LabelPdfResult: @@ -1105,7 +1518,7 @@ def model_dump(self) -> dict[str, object]: @dataclass(slots=True, frozen=True) -class DeliveryEntityResult(SerializableModel): +class DeliveryEntityResult(ApiModel): """Результат операции delivery API.""" success: bool @@ -1115,9 +1528,29 @@ class DeliveryEntityResult(SerializableModel): status: DeliveryOperationStatus | None = None message: str | None = None - -@dataclass(slots=True, frozen=True) -class DeliverySortingCenter(SerializableModel): + @classmethod + def from_payload(cls, payload: object) -> DeliveryEntityResult: + data = _expect_mapping(payload) + result = _mapping(data, "result", "data") + source = result or data + task_id = _str(source, "taskId", "taskID") + task_int = _int(source, "taskId", "taskID") + return cls( + success=bool(source.get("success", data.get("success", True))), + 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=map_enum_or_unknown( + _str(source, "status"), + DeliveryOperationStatus, + enum_name="orders.delivery_operation_status", + ), + message=_str(_mapping(data, "error"), "message") or _str(source, "message"), + ) + + +@dataclass(slots=True, frozen=True) +class DeliverySortingCenter(ApiModel): """Сортировочный центр доставки.""" sorting_center_id: str | None @@ -1126,23 +1559,56 @@ class DeliverySortingCenter(SerializableModel): @dataclass(slots=True, frozen=True) -class DeliverySortingCentersResult(SerializableModel): +class DeliverySortingCentersResult(ApiModel): """Список сортировочных центров доставки.""" items: list[DeliverySortingCenter] - -@dataclass(slots=True, frozen=True) -class DeliveryTaskInfo(SerializableModel): + @classmethod + def from_payload(cls, payload: object) -> DeliverySortingCentersResult: + data = _expect_mapping(payload) + result = _mapping(data, "result", "data") + source = result or data + return cls( + items=[ + DeliverySortingCenter( + sorting_center_id=_str(item, "id", "sortingCenterId", "sorting_center_id"), + name=_str(item, "name"), + city=_str(item, "city"), + ) + for item in _list(source, "sortingCenters", "items", "result") + ], + ) + + +@dataclass(slots=True, frozen=True) +class DeliveryTaskInfo(ApiModel): """Информация о задаче доставки.""" task_id: str | None status: DeliveryTaskState | None error: str | None - -@dataclass(slots=True, frozen=True) -class StockInfo(SerializableModel): + @classmethod + def from_payload(cls, payload: object) -> DeliveryTaskInfo: + data = _expect_mapping(payload) + result = _mapping(data, "result", "data") + source = result or data + task_id = _str(source, "taskId", "taskID", "id") + task_int = _int(source, "taskId", "taskID") + return cls( + task_id=task_id or (str(task_int) if task_int is not None else None), + status=map_enum_or_unknown( + _str(source, "status"), + DeliveryTaskState, + enum_name="orders.delivery_task_state", + ), + error=_str(_mapping(data, "error"), "message") or _str(source, "error"), + ) + + +@dataclass(slots=True, frozen=True) +class StockInfo(ApiModel): """Информация по остаткам объявления.""" item_id: int | None @@ -1153,14 +1619,30 @@ class StockInfo(SerializableModel): @dataclass(slots=True, frozen=True) -class StockInfoResult(SerializableModel): +class StockInfoResult(ApiModel): """Список текущих остатков.""" items: list[StockInfo] - -@dataclass(slots=True, frozen=True) -class StockUpdateItem(SerializableModel): + @classmethod + def from_payload(cls, payload: object) -> StockInfoResult: + data = _expect_mapping(payload) + return cls( + items=[ + StockInfo( + item_id=_int(item, "item_id", "itemId"), + quantity=_int(item, "quantity"), + is_multiple=_bool(item, "is_multiple", "isMultiple"), + is_unlimited=_bool(item, "is_unlimited", "isUnlimited"), + is_out_of_stock=_bool(item, "is_out_of_stock", "isOutOfStock"), + ) + for item in _list(data, "stocks", "items", "result") + ], + ) + + +@dataclass(slots=True, frozen=True) +class StockUpdateItem(ApiModel): """Результат обновления остатков объявления.""" item_id: int | None @@ -1170,7 +1652,22 @@ class StockUpdateItem(SerializableModel): @dataclass(slots=True, frozen=True) -class StockUpdateResult(SerializableModel): +class StockUpdateResult(ApiModel): """Результат изменения остатков.""" items: list[StockUpdateItem] + + @classmethod + def from_payload(cls, payload: object) -> StockUpdateResult: + data = _expect_mapping(payload) + return cls( + items=[ + StockUpdateItem( + item_id=_int(item, "item_id", "itemId"), + external_id=_str(item, "external_id", "externalId"), + success=bool(item.get("success", True)), + errors=_extract_errors(item), + ) + for item in _list(data, "stocks", "items", "result") + ], + ) diff --git a/avito/orders/operations.py b/avito/orders/operations.py new file mode 100644 index 0000000..be5218e --- /dev/null +++ b/avito/orders/operations.py @@ -0,0 +1,407 @@ +"""Operation specs for orders domain.""" + +from __future__ import annotations + +from avito.core import BinaryResponse, OperationSpec +from avito.orders.models import ( + AddSortingCentersRequest, + AddTariffV2Request, + AddTerminalsRequest, + CancelParcelRequest, + CancelSandboxParcelRequest, + ChangeParcelRequest, + CourierRangesResult, + CustomAreaScheduleRequest, + DeliveryAnnouncementRequest, + DeliveryAnnouncementTrackRequest, + DeliveryCancelAnnouncementRequest, + DeliveryEntityResult, + DeliveryParcelIdsRequest, + DeliveryParcelRequest, + DeliveryParcelResultRequest, + DeliverySandboxAnnouncementRequest, + DeliverySortingCentersResult, + DeliveryTaskInfo, + DeliveryTrackingRequest, + GetChangeParcelInfoRequest, + GetRegisteredParcelIdRequest, + GetSandboxParcelInfoRequest, + LabelTaskResult, + OrderAcceptReturnRequest, + OrderActionResult, + OrderApplyTransitionRequest, + OrderCncDetailsRequest, + OrderConfirmationCodeRequest, + OrderCourierRangeRequest, + OrderLabelsRequest, + OrderMarkingsRequest, + OrdersResult, + OrderTrackingNumberRequest, + ProhibitOrderAcceptanceRequest, + SandboxAreasRequest, + SandboxCancelAnnouncementRequest, + SandboxConfirmationCodeRequest, + SandboxCreateAnnouncementRequest, + SandboxGetAnnouncementEventRequest, + SandboxParcelRequest, + SetOrderPropertiesRequest, + SetOrderRealAddressRequest, + StockInfoRequest, + StockInfoResult, + StockUpdateRequest, + StockUpdateResult, + TaggedSortingCentersRequest, + UpdateTermsRequest, +) + +LIST_ORDERS = OperationSpec( + name="orders.list_orders", + method="GET", + path="/order-management/1/orders", + response_model=OrdersResult, +) +UPDATE_MARKINGS = OperationSpec( + name="orders.update_markings", + method="POST", + path="/order-management/1/markings", + request_model=OrderMarkingsRequest, + response_model=OrderActionResult, + retry_mode="enabled", +) +ACCEPT_RETURN_ORDER = OperationSpec( + name="orders.accept_return_order", + method="POST", + path="/order-management/1/order/acceptReturnOrder", + request_model=OrderAcceptReturnRequest, + response_model=OrderActionResult, + retry_mode="enabled", +) +APPLY_TRANSITION = OperationSpec( + name="orders.apply_transition", + method="POST", + path="/order-management/1/order/applyTransition", + request_model=OrderApplyTransitionRequest, + response_model=OrderActionResult, + retry_mode="enabled", +) +CHECK_CONFIRMATION_CODE = OperationSpec( + name="orders.check_confirmation_code", + method="POST", + path="/order-management/1/order/checkConfirmationCode", + request_model=OrderConfirmationCodeRequest, + response_model=OrderActionResult, + retry_mode="enabled", +) +SET_CNC_DETAILS = OperationSpec( + name="orders.set_cnc_details", + method="POST", + path="/order-management/1/order/cncSetDetails", + request_model=OrderCncDetailsRequest, + response_model=OrderActionResult, + retry_mode="enabled", +) +GET_COURIER_DELIVERY_RANGE = OperationSpec( + name="orders.get_courier_delivery_range", + method="GET", + path="/order-management/1/order/getCourierDeliveryRange", + response_model=CourierRangesResult, +) +SET_COURIER_DELIVERY_RANGE = OperationSpec( + name="orders.set_courier_delivery_range", + method="POST", + path="/order-management/1/order/setCourierDeliveryRange", + request_model=OrderCourierRangeRequest, + response_model=OrderActionResult, + retry_mode="enabled", +) +SET_TRACKING_NUMBER = OperationSpec( + name="orders.set_tracking_number", + method="POST", + path="/order-management/1/order/setTrackingNumber", + request_model=OrderTrackingNumberRequest, + response_model=OrderActionResult, + retry_mode="enabled", +) +CREATE_LABELS = OperationSpec( + name="orders.labels.create", + method="POST", + path="/order-management/1/orders/labels", + request_model=OrderLabelsRequest, + response_model=LabelTaskResult, + retry_mode="enabled", +) +CREATE_LABELS_EXTENDED = OperationSpec( + name="orders.labels.create_extended", + method="POST", + path="/order-management/1/orders/labels/extended", + request_model=OrderLabelsRequest, + response_model=LabelTaskResult, + retry_mode="enabled", +) +DOWNLOAD_LABEL: OperationSpec[BinaryResponse] = OperationSpec( + name="orders.labels.download", + method="GET", + path="/order-management/1/orders/labels/{taskID}/download", + response_kind="binary", +) +DELIVERY_CREATE_ANNOUNCEMENT = OperationSpec( + name="orders.delivery.create_announcement", + method="POST", + path="/createAnnouncement", + request_model=DeliveryAnnouncementRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +DELIVERY_CANCEL_ANNOUNCEMENT = OperationSpec( + name="orders.delivery.cancel_announcement", + method="POST", + path="/cancelAnnouncement", + request_model=DeliveryCancelAnnouncementRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +DELIVERY_CREATE_PARCEL = OperationSpec( + name="orders.delivery.create_parcel", + method="POST", + path="/createParcel", + request_model=DeliveryParcelRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +DELIVERY_UPDATE_CHANGE_PARCELS = OperationSpec( + name="orders.delivery.update_change_parcels", + method="POST", + path="/sandbox/changeParcels", + request_model=DeliveryParcelIdsRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +DELIVERY_CHANGE_PARCEL_RESULT = OperationSpec( + name="orders.delivery.change_parcel_result", + method="POST", + path="/delivery/order/changeParcelResult", + request_model=DeliveryParcelResultRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_CREATE_ANNOUNCEMENT = OperationSpec( + name="orders.sandbox.create_announcement", + method="POST", + path="/delivery-sandbox/announcements/create", + request_model=DeliverySandboxAnnouncementRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_TRACK_ANNOUNCEMENT = OperationSpec( + name="orders.sandbox.track_announcement", + method="POST", + path="/delivery-sandbox/announcements/track", + request_model=DeliveryAnnouncementTrackRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_UPDATE_CUSTOM_AREA_SCHEDULE = OperationSpec( + name="orders.sandbox.update_custom_area_schedule", + method="POST", + path="/delivery-sandbox/areas/custom-schedule", + request_model=CustomAreaScheduleRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_CANCEL_PARCEL = OperationSpec( + name="orders.sandbox.cancel_parcel", + method="POST", + path="/delivery-sandbox/cancelParcel", + request_model=CancelParcelRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_CHECK_CONFIRMATION_CODE = OperationSpec( + name="orders.sandbox.check_confirmation_code", + method="POST", + path="/delivery-sandbox/order/checkConfirmationCode", + request_model=SandboxConfirmationCodeRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_SET_ORDER_PROPERTIES = OperationSpec( + name="orders.sandbox.set_order_properties", + method="POST", + path="/delivery-sandbox/order/properties", + request_model=SetOrderPropertiesRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_SET_ORDER_REAL_ADDRESS = OperationSpec( + name="orders.sandbox.set_order_real_address", + method="POST", + path="/delivery-sandbox/order/realAddress", + request_model=SetOrderRealAddressRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_TRACKING = OperationSpec( + name="orders.sandbox.tracking", + method="POST", + path="/delivery-sandbox/order/tracking", + request_model=DeliveryTrackingRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_PROHIBIT_ORDER_ACCEPTANCE = OperationSpec( + name="orders.sandbox.prohibit_order_acceptance", + method="POST", + path="/delivery-sandbox/prohibitOrderAcceptance", + request_model=ProhibitOrderAcceptanceRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_LIST_SORTING_CENTER = OperationSpec( + name="orders.sandbox.list_sorting_center", + method="GET", + path="/delivery-sandbox/sorting-center", + response_model=DeliverySortingCentersResult, +) +SANDBOX_ADD_SORTING_CENTER = OperationSpec( + name="orders.sandbox.add_sorting_center", + method="POST", + path="/delivery-sandbox/tariffs/sorting-center", + request_model=AddSortingCentersRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_ADD_AREAS = OperationSpec( + name="orders.sandbox.add_areas", + method="POST", + path="/delivery-sandbox/tariffs/{tariff_id}/areas", + request_model=SandboxAreasRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_ADD_TAGS_TO_SORTING_CENTER = OperationSpec( + name="orders.sandbox.add_tags_to_sorting_center", + method="POST", + path="/delivery-sandbox/tariffs/{tariff_id}/tagged-sorting-centers", + request_model=TaggedSortingCentersRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_ADD_TERMINALS = OperationSpec( + name="orders.sandbox.add_terminals", + method="POST", + path="/delivery-sandbox/tariffs/{tariff_id}/terminals", + request_model=AddTerminalsRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_UPDATE_TERMS = OperationSpec( + name="orders.sandbox.update_terms", + method="POST", + path="/delivery-sandbox/tariffs/{tariff_id}/terms", + request_model=UpdateTermsRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_ADD_TARIFF = OperationSpec( + name="orders.sandbox.add_tariff", + method="POST", + path="/delivery-sandbox/tariffsV2", + request_model=AddTariffV2Request, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_CREATE_PARCEL = OperationSpec( + name="orders.sandbox.create_parcel", + method="POST", + path="/delivery-sandbox/v2/createParcel", + request_model=SandboxParcelRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_CANCEL_SANDBOX_ANNOUNCEMENT = OperationSpec( + name="orders.sandbox.cancel_sandbox_announcement", + method="POST", + path="/delivery-sandbox/v1/cancelAnnouncement", + request_model=SandboxCancelAnnouncementRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_CANCEL_SANDBOX_PARCEL = OperationSpec( + name="orders.sandbox.cancel_sandbox_parcel", + method="POST", + path="/delivery-sandbox/v1/cancelParcel", + request_model=CancelSandboxParcelRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_CHANGE_SANDBOX_PARCEL = OperationSpec( + name="orders.sandbox.change_sandbox_parcel", + method="POST", + path="/delivery-sandbox/v1/changeParcel", + request_model=ChangeParcelRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_CREATE_SANDBOX_ANNOUNCEMENT = OperationSpec( + name="orders.sandbox.create_sandbox_announcement", + method="POST", + path="/delivery-sandbox/v1/createAnnouncement", + request_model=SandboxCreateAnnouncementRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_GET_ANNOUNCEMENT_EVENT = OperationSpec( + name="orders.sandbox.get_sandbox_announcement_event", + method="POST", + path="/delivery-sandbox/v1/getAnnouncementEvent", + request_model=SandboxGetAnnouncementEventRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_GET_CHANGE_PARCEL_INFO = OperationSpec( + name="orders.sandbox.get_sandbox_change_parcel_info", + method="POST", + path="/delivery-sandbox/v1/getChangeParcelInfo", + request_model=GetChangeParcelInfoRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_GET_PARCEL_INFO = OperationSpec( + name="orders.sandbox.get_sandbox_parcel_info", + method="POST", + path="/delivery-sandbox/v1/getParcelInfo", + request_model=GetSandboxParcelInfoRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +SANDBOX_GET_REGISTERED_PARCEL_ID = OperationSpec( + name="orders.sandbox.get_sandbox_registered_parcel_id", + method="POST", + path="/delivery-sandbox/v1/getRegisteredParcelID", + request_model=GetRegisteredParcelIdRequest, + response_model=DeliveryEntityResult, + retry_mode="enabled", +) +GET_DELIVERY_TASK = OperationSpec( + name="orders.delivery_task.get_task", + method="GET", + path="/delivery-sandbox/tasks/{task_id}", + response_model=DeliveryTaskInfo, + retry_mode="enabled", +) +GET_STOCK_INFO = OperationSpec( + name="orders.stock.get_info", + method="POST", + path="/stock-management/1/info", + request_model=StockInfoRequest, + response_model=StockInfoResult, + retry_mode="enabled", +) +UPDATE_STOCKS = OperationSpec( + name="orders.stock.update_stocks", + method="PUT", + path="/stock-management/1/stocks", + request_model=StockUpdateRequest, + response_model=StockUpdateResult, + retry_mode="enabled", +) diff --git a/avito/promotion/__init__.py b/avito/promotion/__init__.py index 4435a9c..6c14cbe 100644 --- a/avito/promotion/__init__.py +++ b/avito/promotion/__init__.py @@ -8,14 +8,6 @@ TargetActionPricing, TrxPromotion, ) -from avito.promotion.enums import ( - CampaignType, - PromotionOrderServiceStatus, - PromotionOrderStatus, - PromotionStatus, - TargetActionBudgetType, - TargetActionSelectedType, -) from avito.promotion.models import ( AutostrategyBudget, AutostrategyStat, @@ -36,29 +28,36 @@ CampaignListFilter, CampaignOrderBy, CampaignsResult, + CampaignType, CampaignUpdateTimeFilter, + CpaAuctionBidInput, CpaAuctionBidsResult, CreateItemBid, PromotionActionResult, PromotionForecast, PromotionOrderError, PromotionOrderInfo, + PromotionOrderServiceStatus, PromotionOrdersResult, + PromotionOrderStatus, PromotionOrderStatusItem, PromotionOrderStatusResult, PromotionService, PromotionServiceDictionary, PromotionServicesResult, PromotionServiceType, + PromotionStatus, TargetActionAutoBids, TargetActionAutoPromotion, TargetActionBid, TargetActionBudget, + TargetActionBudgetType, TargetActionGetBidsResult, TargetActionManualBids, TargetActionManualPromotion, TargetActionPromotion, TargetActionPromotionsByItemIdsResult, + TargetActionSelectedType, TrxCommissionsResult, TrxItem, ) @@ -88,6 +87,7 @@ "CampaignUpdateTimeFilter", "CampaignsResult", "CpaAuction", + "CpaAuctionBidInput", "CpaAuctionBidsResult", "CreateItemBid", "PromotionActionResult", diff --git a/avito/promotion/client.py b/avito/promotion/client.py deleted file mode 100644 index d0313f7..0000000 --- a/avito/promotion/client.py +++ /dev/null @@ -1,633 +0,0 @@ -"""Внутренние section clients для пакета promotion.""" - -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, - map_bbip_forecasts, - map_bbip_suggests, - map_campaign_action, - map_campaign_info, - map_campaigns, - map_cpa_auction_bids, - map_promotion_action, - map_promotion_order_status, - map_promotion_orders, - map_promotion_service_dictionary, - map_promotion_services, - map_target_action_get_bids_out, - map_target_action_get_promotions_by_item_ids_out, - map_trx_commissions, -) -from avito.promotion.models import ( - AutostrategyBudget, - AutostrategyStat, - BbipForecastsResult, - BbipItem, - BbipSuggestsResult, - CampaignActionResult, - CampaignDetailsResult, - CampaignListFilter, - CampaignOrderBy, - CampaignsResult, - 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, - UpdateAutoBidRequest, - UpdateAutostrategyCampaignRequest, - UpdateManualBidRequest, -) - -_TRX_HEADERS = { - "x-authenticated-userid": "7", - "x-oauth-flow": "client_credentials", -} - - -@dataclass(slots=True, frozen=True) -class PromotionClient: - """Выполняет HTTP-операции общего promotion API.""" - - transport: Transport - - def get_service_dictionary(self) -> PromotionServiceDictionary: - """Получает словарь услуг продвижения.""" - - return request_public_model( - self.transport, - "POST", - "/promotion/v1/items/services/dict", - context=RequestContext("promotion.get_service_dictionary", allow_retry=True), - mapper=map_promotion_service_dictionary, - ) - - def list_services(self, *, item_ids: list[int]) -> PromotionServicesResult: - """Получает список услуг продвижения по объявлениям.""" - - return request_public_model( - self.transport, - "POST", - "/promotion/v1/items/services/get", - context=RequestContext("promotion.list_services", allow_retry=True), - mapper=map_promotion_services, - json_body=ListPromotionServicesRequest(item_ids=item_ids).to_payload(), - ) - - def list_orders( - self, *, item_ids: list[int] | None = None, order_ids: list[str] | None = None - ) -> PromotionOrdersResult: - """Получает список заявок на продвижение.""" - - return request_public_model( - self.transport, - "POST", - "/promotion/v1/items/services/orders/get", - context=RequestContext("promotion.list_orders", allow_retry=True), - mapper=map_promotion_orders, - json_body=ListPromotionOrdersRequest(item_ids=item_ids, order_ids=order_ids).to_payload(), - ) - - def get_order_status(self, *, order_ids: list[str]) -> PromotionOrderStatusResult: - """Получает статусы заявок на продвижение.""" - - return request_public_model( - self.transport, - "POST", - "/promotion/v1/items/services/orders/status", - context=RequestContext("promotion.get_order_status", allow_retry=True), - mapper=map_promotion_order_status, - json_body=GetPromotionOrderStatusRequest(order_ids=order_ids).to_payload(), - ) - - -@dataclass(slots=True, frozen=True) -class BbipClient: - """Выполняет HTTP-операции BBIP-продвижения.""" - - transport: Transport - - def get_forecasts(self, *, items: list[BbipItem]) -> BbipForecastsResult: - """Получает прогнозы BBIP по объявлениям.""" - - return request_public_model( - self.transport, - "POST", - "/promotion/v1/items/services/bbip/forecasts/get", - context=RequestContext("promotion.bbip.get_forecasts", allow_retry=True), - mapper=map_bbip_forecasts, - json_body=CreateBbipForecastsRequest(items=items).to_payload(), - ) - - def create_order( - self, - *, - items: list[BbipItem], - idempotency_key: str | None = None, - ) -> PromotionActionResult: - """Подключает BBIP-услугу.""" - - 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=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, - idempotency_key=idempotency_key, - ) - - def get_suggests(self, *, item_ids: list[int]) -> BbipSuggestsResult: - """Получает варианты бюджета BBIP.""" - - return request_public_model( - self.transport, - "POST", - "/promotion/v1/items/services/bbip/suggests/get", - context=RequestContext("promotion.bbip.get_suggests", allow_retry=True), - mapper=map_bbip_suggests, - json_body=CreateBbipSuggestsRequest(item_ids=item_ids).to_payload(), - ) - - -@dataclass(slots=True, frozen=True) -class TrxPromoClient: - """Выполняет HTTP-операции TrxPromo.""" - - transport: Transport - - def apply( - self, - *, - items: list[TrxItem], - idempotency_key: str | None = None, - ) -> PromotionActionResult: - """Запускает TrxPromo.""" - - 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=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, - headers=_TRX_HEADERS, - idempotency_key=idempotency_key, - ) - - def cancel( - self, - *, - item_ids: list[int], - idempotency_key: str | None = None, - ) -> PromotionActionResult: - """Останавливает TrxPromo.""" - - 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=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, - headers=_TRX_HEADERS, - idempotency_key=idempotency_key, - ) - - def get_commissions(self, *, item_ids: list[int] | None = None) -> TrxCommissionsResult: - """Проверяет доступность TrxPromo и размер комиссий.""" - - params = {"itemIDs": ",".join(str(item_id) for item_id in item_ids)} if item_ids else None - return request_public_model( - self.transport, - "GET", - "/trx-promo/1/commissions", - context=RequestContext("promotion.trx.get_commissions"), - mapper=map_trx_commissions, - params=params, - headers=_TRX_HEADERS, - ) - - -@dataclass(slots=True, frozen=True) -class CpaAuctionClient: - """Выполняет HTTP-операции CPA-аукциона.""" - - transport: Transport - - def get_user_bids( - self, - *, - from_item_id: int | None = None, - batch_size: int | None = None, - ) -> CpaAuctionBidsResult: - """Получает действующие и доступные ставки.""" - - return request_public_model( - self.transport, - "GET", - "/auction/1/bids", - context=RequestContext("promotion.cpa_auction.get_user_bids"), - mapper=map_cpa_auction_bids, - params={"fromItemID": from_item_id, "batchSize": batch_size}, - ) - - def create_item_bids( - self, - *, - items: list[CreateItemBid], - idempotency_key: str | None = None, - ) -> PromotionActionResult: - """Сохраняет новые ставки.""" - - 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=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, - idempotency_key=idempotency_key, - ) - - -@dataclass(slots=True, frozen=True) -class TargetActionPriceClient: - """Выполняет HTTP-операции цены целевого действия.""" - - transport: Transport - - def get_bids(self, *, item_id: int) -> TargetActionGetBidsResult: - """Получает детализированные цены и бюджеты по объявлению.""" - - return request_public_model( - self.transport, - "GET", - f"/cpxpromo/1/getBids/{item_id}", - context=RequestContext("promotion.target_action.get_bids"), - mapper=map_target_action_get_bids_out, - ) - - def get_promotions_by_item_ids( - self, - item_ids: list[int], - ) -> TargetActionPromotionsByItemIdsResult: - """Получает текущие цены и бюджеты по нескольким объявлениям.""" - - return request_public_model( - self.transport, - "POST", - "/cpxpromo/1/getPromotionsByItemIds", - context=RequestContext( - "promotion.target_action.get_promotions_by_item_ids", allow_retry=True - ), - mapper=map_target_action_get_promotions_by_item_ids_out, - json_body=GetPromotionsByItemIdsRequest(item_ids=item_ids).to_payload(), - ) - - def delete_promotion( - self, - *, - item_id: int, - idempotency_key: str | None = None, - ) -> PromotionActionResult: - """Останавливает продвижение с ценой целевого действия.""" - - 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=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, - idempotency_key=idempotency_key, - ) - - def update_auto_bid( - self, - *, - item_id: int, - action_type_id: int, - budget_penny: int, - budget_type: TargetActionBudgetType | str, - idempotency_key: str | None = None, - ) -> PromotionActionResult: - """Применяет автоматическую настройку.""" - - 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=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, - idempotency_key=idempotency_key, - ) - - def update_manual_bid( - self, - *, - item_id: int, - action_type_id: int, - bid_penny: int, - limit_penny: int | None = None, - idempotency_key: str | None = None, - ) -> PromotionActionResult: - """Применяет ручную настройку.""" - - 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=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, - idempotency_key=idempotency_key, - ) - - -@dataclass(slots=True, frozen=True) -class AutostrategyClient: - """Выполняет HTTP-операции автостратегии.""" - - transport: Transport - - 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( - self.transport, - "POST", - "/autostrategy/v1/budget", - context=RequestContext("promotion.autostrategy.create_budget", allow_retry=True), - mapper=map_autostrategy_budget, - json_body=CreateAutostrategyBudgetRequest( - campaign_type=campaign_type, - start_time=start_time, - finish_time=finish_time, - items=items, - ).to_payload(), - ) - - 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=idempotency_key is not None, - ), - mapper=map_campaign_action, - 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, - *, - 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=idempotency_key is not None, - ), - mapper=map_campaign_action, - 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, *, campaign_id: int) -> CampaignDetailsResult: - """Получает полную информацию о кампании.""" - - return request_public_model( - self.transport, - "POST", - "/autostrategy/v1/campaign/info", - context=RequestContext("promotion.autostrategy.get_campaign_info", allow_retry=True), - mapper=map_campaign_info, - json_body=GetAutostrategyCampaignInfoRequest(campaign_id=campaign_id).to_payload(), - ) - - 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=idempotency_key is not None, - ), - mapper=map_campaign_action, - json_body=StopAutostrategyCampaignRequest( - campaign_id=campaign_id, - version=version, - ).to_payload(), - idempotency_key=idempotency_key, - ) - - 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( - self.transport, - "POST", - "/autostrategy/v1/campaigns", - context=RequestContext("promotion.autostrategy.list_campaigns", allow_retry=True), - mapper=map_campaigns, - json_body=ListAutostrategyCampaignsRequest( - limit=limit, - offset=offset, - status_id=status_id, - order_by=order_by, - filter=filter, - ).to_payload(), - ) - - def get_stat(self, *, campaign_id: int) -> AutostrategyStat: - """Получает статистику кампании.""" - - return request_public_model( - self.transport, - "POST", - "/autostrategy/v1/stat", - context=RequestContext("promotion.autostrategy.get_stat", allow_retry=True), - mapper=map_autostrategy_stat, - json_body=GetAutostrategyStatRequest(campaign_id=campaign_id).to_payload(), - ) - - -__all__ = ( - "AutostrategyClient", - "BbipClient", - "CpaAuctionClient", - "PromotionClient", - "TargetActionPriceClient", - "TrxPromoClient", -) diff --git a/avito/promotion/domain.py b/avito/promotion/domain.py index 95e0d56..b26bdda 100644 --- a/avito/promotion/domain.py +++ b/avito/promotion/domain.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import datetime -from avito.core import ValidationError +from avito.core import ApiTimeouts, RetryOverride, ValidationError from avito.core.domain import DomainObject from avito.core.swagger import swagger_operation from avito.core.validation import ( @@ -15,48 +15,82 @@ validate_non_empty_string, validate_positive_int, ) -from avito.promotion.client import ( - AutostrategyClient, - BbipClient, - CpaAuctionClient, - PromotionClient, - TargetActionPriceClient, - TrxPromoClient, -) -from avito.promotion.enums import CampaignType, PromotionStatus, TargetActionBudgetType from avito.promotion.models import ( AutostrategyBudget, AutostrategyStat, BbipForecastsResult, BbipItem, - BbipItemInput, BbipSuggestsResult, - BidItemInput, CampaignActionResult, CampaignDetailsResult, CampaignListFilter, CampaignOrderBy, CampaignsResult, + CampaignType, CampaignUpdateTimeFilter, CancelTrxPromotionRequest, + CpaAuctionBidInput, CpaAuctionBidsResult, + CreateAutostrategyBudgetRequest, + CreateAutostrategyCampaignRequest, + CreateBbipForecastsRequest, CreateBbipOrderRequest, + CreateBbipSuggestsRequest, CreateItemBid, + CreateItemBidsRequest, CreateTrxPromotionApplyRequest, DeletePromotionRequest, + GetAutostrategyCampaignInfoRequest, + GetAutostrategyStatRequest, + GetPromotionOrderStatusRequest, + GetPromotionsByItemIdsRequest, + GetTrxCommissionsRequest, + ListAutostrategyCampaignsRequest, + ListPromotionOrdersRequest, + ListPromotionServicesRequest, PromotionActionResult, PromotionOrdersResult, PromotionOrderStatusResult, PromotionServiceDictionary, PromotionServicesResult, + PromotionStatus, + StopAutostrategyCampaignRequest, + TargetActionBudgetType, TargetActionGetBidsResult, TargetActionPromotionsByItemIdsResult, TrxCommissionsResult, TrxItem, - TrxItemInput, UpdateAutoBidRequest, + UpdateAutostrategyCampaignRequest, UpdateManualBidRequest, ) +from avito.promotion.operations import ( + APPLY_TRX, + CANCEL_TRX, + CREATE_AUTOSTRATEGY_BUDGET, + CREATE_AUTOSTRATEGY_CAMPAIGN, + CREATE_BBIP_ORDER, + CREATE_CPA_AUCTION_BIDS, + DELETE_AUTOSTRATEGY_CAMPAIGN, + DELETE_TARGET_ACTION_PROMOTION, + GET_AUTOSTRATEGY_CAMPAIGN, + GET_AUTOSTRATEGY_STAT, + GET_BBIP_FORECASTS, + GET_BBIP_SUGGESTS, + GET_CPA_AUCTION_BIDS, + GET_ORDER_STATUS, + GET_SERVICE_DICTIONARY, + GET_TARGET_ACTION_BIDS, + GET_TARGET_ACTION_PROMOTIONS, + GET_TRX_COMMISSIONS, + LIST_AUTOSTRATEGY_CAMPAIGNS, + LIST_ORDERS, + LIST_SERVICES, + TRX_HEADERS, + UPDATE_AUTOSTRATEGY_CAMPAIGN, + UPDATE_TARGET_ACTION_AUTO, + UPDATE_TARGET_ACTION_MANUAL, +) def _preview_result( @@ -96,13 +130,26 @@ class PromotionOrder(DomainObject): spec="Продвижение.json", operation_id="get_dict_of_services_v1", ) - def get_service_dictionary(self) -> PromotionServiceDictionary: + def get_service_dictionary( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> PromotionServiceDictionary: """Получает словарь услуг продвижения. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionServiceDictionary` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return PromotionClient(self.transport).get_service_dictionary() + return self._execute(GET_SERVICE_DICTIONARY, timeout=timeout, retry=retry) @swagger_operation( "POST", @@ -111,15 +158,36 @@ def get_service_dictionary(self) -> PromotionServiceDictionary: operation_id="get_services_by_items_v1", method_args={"item_ids": "body.item_ids"}, ) - def list_services(self, *, item_ids: list[int]) -> PromotionServicesResult: - """Получает список услуг продвижения по объявлениям. + def list_services( + self, + *, + item_ids: list[int], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionServicesResult: + """Возвращает доступные услуги продвижения для объявлений. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Аргументы: + item_ids: передает идентификаторы объявлений или товаров. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Возвращает: + `PromotionServicesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return PromotionClient(self.transport).list_services(item_ids=item_ids) + return self._execute( + LIST_SERVICES, + request=ListPromotionServicesRequest(item_ids=item_ids), + timeout=timeout, + retry=retry, + ) @swagger_operation( "POST", @@ -132,17 +200,32 @@ def list_orders( *, item_ids: list[int] | None = None, order_ids: list[str] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> PromotionOrdersResult: - """Получает список заявок на продвижение. + """Возвращает заказы продвижения по объявлениям или идентификаторам заказов. + + Аргументы: + item_ids: передает идентификаторы объявлений или товаров. + order_ids: передает идентификаторы заказов. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `PromotionOrdersResult` с типизированными данными ответа API. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return PromotionClient(self.transport).list_orders( - item_ids=item_ids, - order_ids=order_ids, + return self._execute( + LIST_ORDERS, + request=ListPromotionOrdersRequest(item_ids=item_ids, order_ids=order_ids), + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -151,10 +234,28 @@ def list_orders( spec="Продвижение.json", operation_id="get_order_status_v1", ) - def get_order_status(self, *, order_ids: list[str] | None = None) -> PromotionOrderStatusResult: + def get_order_status( + self, + *, + order_ids: list[str] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionOrderStatusResult: """Получает статусы заявок на продвижение. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + order_ids: идентификаторы заказов продвижения. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionOrderStatusResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ resolved_order_ids = order_ids or ( @@ -162,7 +263,12 @@ 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(order_ids=resolved_order_ids) + return self._execute( + GET_ORDER_STATUS, + request=GetPromotionOrderStatusRequest(order_ids=resolved_order_ids), + timeout=timeout, + retry=retry, + ) @dataclass(slots=True, frozen=True) @@ -183,22 +289,36 @@ class BbipPromotion(DomainObject): operation_id="get_bbip_forecasts_by_items_v1", method_args={"items": "body.items"}, ) - def get_forecasts(self, *, items: list[BbipItemInput]) -> BbipForecastsResult: + def get_forecasts( + self, + *, + items: list[BbipItem], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> BbipForecastsResult: """Получает прогнозы BBIP. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + items: элементы запроса с объявлениями, ставками или настройками продвижения. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `BbipForecastsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - bbip_items = [ - BbipItem( - item_id=item["item_id"], - duration=item["duration"], - price=item["price"], - old_price=item["old_price"], - ) - for item in items - ] - return BbipClient(self.transport).get_forecasts(items=bbip_items) + return self._execute( + GET_BBIP_FORECASTS, + request=CreateBbipForecastsRequest(items=list(items)), + timeout=timeout, + retry=retry, + ) @swagger_operation( "PUT", @@ -210,45 +330,60 @@ def get_forecasts(self, *, items: list[BbipItemInput]) -> BbipForecastsResult: def create_order( self, *, - items: list[BbipItemInput], + items: list[BbipItem], dry_run: bool = False, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> PromotionActionResult: """Подключает BBIP-продвижение. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + items: элементы запроса с объявлениями, ставками или настройками продвижения. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. - При `dry_run=True` payload строится без вызова транспорта. + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ validate_non_empty("items", items) for index, item in enumerate(items): - validate_positive_int(f"items[{index}].item_id", item["item_id"]) - validate_positive_int(f"items[{index}].duration", item["duration"]) - validate_positive_int(f"items[{index}].price", item["price"]) - validate_positive_int(f"items[{index}].old_price", item["old_price"]) - bbip_items = [ - BbipItem( - item_id=item["item_id"], - duration=item["duration"], - price=item["price"], - old_price=item["old_price"], - ) - for item in items - ] + validate_positive_int(f"items[{index}].item_id", item.item_id) + validate_positive_int(f"items[{index}].duration", item.duration) + validate_positive_int(f"items[{index}].price", item.price) + validate_positive_int(f"items[{index}].old_price", item.old_price) + bbip_items = list(items) request_payload = CreateBbipOrderRequest(items=bbip_items).to_payload() - target: dict[str, object] = {"item_ids": [item["item_id"] for item in items]} + target: dict[str, object] = {"item_ids": [item.item_id for item in items]} if dry_run: return _preview_result( action="create_order", target=target, request_payload=request_payload, ) - return BbipClient(self.transport).create_order( - items=bbip_items, + payload = self._execute( + CREATE_BBIP_ORDER, + request=CreateBbipOrderRequest(items=bbip_items), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="create_order", + target=target, + request_payload=request_payload, ) @swagger_operation( @@ -257,14 +392,37 @@ def create_order( spec="Продвижение.json", operation_id="get_bbip_suggests_by_items_v1", ) - def get_suggests(self, *, item_ids: list[int] | None = None) -> BbipSuggestsResult: + def get_suggests( + self, + *, + item_ids: list[int] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> BbipSuggestsResult: """Получает варианты бюджета BBIP. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `BbipSuggestsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ resolved_item_ids = item_ids or self._resource_item_ids() - return BbipClient(self.transport).get_suggests(item_ids=resolved_item_ids) + return self._execute( + GET_BBIP_SUGGESTS, + request=CreateBbipSuggestsRequest(item_ids=resolved_item_ids), + timeout=timeout, + retry=retry, + ) def _resource_item_ids(self) -> list[int]: if self.item_id is None: @@ -293,41 +451,57 @@ class TrxPromotion(DomainObject): def apply( self, *, - items: list[TrxItemInput], + items: list[TrxItem], dry_run: bool = False, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> PromotionActionResult: """Запускает TrxPromo. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + items: элементы запроса с объявлениями, ставками или настройками продвижения. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. - При `dry_run=True` payload строится без вызова транспорта. + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ validate_non_empty("items", items) for index, item in enumerate(items): - validate_positive_int(f"items[{index}].item_id", item["item_id"]) - validate_positive_int(f"items[{index}].commission", item["commission"]) - if not isinstance(item.get("date_from"), datetime): + validate_positive_int(f"items[{index}].item_id", item.item_id) + validate_positive_int(f"items[{index}].commission", item.commission) + if not isinstance(item.date_from, datetime): raise ValidationError(f"items[{index}].date_from должен быть datetime.") - trx_items = [ - TrxItem( - item_id=item["item_id"], - commission=item["commission"], - date_from=item["date_from"], - date_to=item.get("date_to"), - ) - for item in items - ] + trx_items = list(items) request_payload = CreateTrxPromotionApplyRequest(items=trx_items).to_payload() - target: dict[str, object] = {"item_ids": [item["item_id"] for item in items]} + 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( - items=trx_items, + payload = self._execute( + APPLY_TRX, + request=CreateTrxPromotionApplyRequest(items=trx_items), + headers=TRX_HEADERS, idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="apply", + target=target, + request_payload=request_payload, ) @swagger_operation( @@ -335,6 +509,7 @@ def apply( "/trx-promo/1/cancel", spec="TrxPromo.json", operation_id="api_trx_promo_open_api_cancel", + method_args={"item_ids": "body.itemIDs"}, ) def delete( self, @@ -342,14 +517,28 @@ def delete( item_ids: list[int] | None = None, dry_run: bool = False, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> PromotionActionResult: """Останавливает TrxPromo. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + item_ids: список идентификаторов объявлений. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. - При `dry_run=True` payload строится без вызова транспорта. + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ resolved_item_ids = item_ids or self._resource_item_ids() @@ -358,9 +547,19 @@ def delete( target = {"item_ids": list(resolved_item_ids)} if dry_run: return _preview_result(action="delete", target=target, request_payload=request_payload) - return TrxPromoClient(self.transport).cancel( - item_ids=resolved_item_ids, + payload = self._execute( + CANCEL_TRX, + request=CancelTrxPromotionRequest(item_ids=resolved_item_ids), + headers=TRX_HEADERS, idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="delete", + target=target, + request_payload=request_payload, ) @swagger_operation( @@ -368,15 +567,39 @@ def delete( "/trx-promo/1/commissions", spec="TrxPromo.json", operation_id="api_trx_promo_open_api_commissions", + method_args={"item_ids": "body.item_ids"}, ) - def get_commissions(self, *, item_ids: list[int] | None = None) -> TrxCommissionsResult: + def get_commissions( + self, + *, + item_ids: list[int] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> TrxCommissionsResult: """Получает доступные комиссии TrxPromo. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `TrxCommissionsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return TrxPromoClient(self.transport).get_commissions( - item_ids=item_ids or self._resource_item_ids() + resolved_item_ids = item_ids or self._resource_item_ids() + return self._execute( + GET_TRX_COMMISSIONS, + request=GetTrxCommissionsRequest(item_ids=resolved_item_ids), + headers=TRX_HEADERS, + timeout=timeout, + retry=retry, ) def _resource_item_ids(self) -> list[int]: @@ -406,15 +629,32 @@ def get_user_bids( *, from_item_id: int | None = None, batch_size: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> CpaAuctionBidsResult: """Получает действующие и доступные ставки. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + from_item_id: идентификатор объявления, с которого начинается выборка. + batch_size: размер пакетной выборки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaAuctionBidsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return CpaAuctionClient(self.transport).get_user_bids( - from_item_id=from_item_id, - batch_size=batch_size, + return self._execute( + GET_CPA_AUCTION_BIDS, + query={"fromItemID": from_item_id, "batchSize": batch_size}, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -427,23 +667,44 @@ def get_user_bids( def create_item_bids( self, *, - items: list[BidItemInput], + items: list[CpaAuctionBidInput], idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> PromotionActionResult: """Сохраняет новые ставки по объявлениям. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + items: элементы запроса с объявлениями, ставками или настройками продвижения. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - bids = [ - CreateItemBid(item_id=item["item_id"], price_penny=item["price_penny"]) - for item in items - ] - return CpaAuctionClient(self.transport).create_item_bids( - items=bids, + bids = [CreateItemBid(item_id=item.item_id, price_penny=item.price_penny) for item in items] + request = CreateItemBidsRequest(items=bids) + payload = self._execute( + CREATE_CPA_AUCTION_BIDS, + request=request, idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="create_item_bids", + target={"item_ids": [item.item_id for item in items]}, + request_payload=request.to_payload(), ) @@ -464,14 +725,35 @@ class TargetActionPricing(DomainObject): spec="Настройкаценыцелевогодействия.json", operation_id="getBids", ) - def get_bids(self, *, item_id: int | None = None) -> TargetActionGetBidsResult: + def get_bids( + self, + *, + item_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> TargetActionGetBidsResult: """Получает детализированные цены и бюджеты. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + item_id: идентификатор объявления. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `TargetActionGetBidsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return TargetActionPriceClient(self.transport).get_bids( - item_id=item_id or self._require_item_id() + return self._execute( + GET_TARGET_ACTION_BIDS, + path_params={"itemId": item_id or self._require_item_id()}, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -481,16 +763,35 @@ def get_bids(self, *, item_id: int | None = None) -> TargetActionGetBidsResult: operation_id="getPromotionsByItemIds", ) def get_promotions_by_item_ids( - self, *, item_ids: list[int] | None = None + self, + *, + item_ids: list[int] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> TargetActionPromotionsByItemIdsResult: """Получает текущие настройки по нескольким объявлениям. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `TargetActionPromotionsByItemIdsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ resolved_item_ids = item_ids or [self._require_item_id()] - return TargetActionPriceClient(self.transport).get_promotions_by_item_ids( - item_ids=resolved_item_ids + return self._execute( + GET_TARGET_ACTION_PROMOTIONS, + request=GetPromotionsByItemIdsRequest(item_ids=resolved_item_ids), + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -505,14 +806,28 @@ def delete( item_id: int | None = None, dry_run: bool = False, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> PromotionActionResult: """Останавливает продвижение. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + item_id: идентификатор объявления. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. - При `dry_run=True` payload строится без вызова транспорта. + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ resolved_item_id = item_id or self._require_item_id() @@ -521,9 +836,18 @@ def delete( target = {"item_id": resolved_item_id} if dry_run: return _preview_result(action="delete", target=target, request_payload=request_payload) - return TargetActionPriceClient(self.transport).delete_promotion( - item_id=resolved_item_id, + payload = self._execute( + DELETE_TARGET_ACTION_PROMOTION, + request=DeletePromotionRequest(item_id=resolved_item_id), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="delete", + target=target, + request_payload=request_payload, ) @swagger_operation( @@ -546,14 +870,31 @@ def update_auto( item_id: int | None = None, dry_run: bool = False, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> PromotionActionResult: """Применяет автоматическую настройку. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. - - При `dry_run=True` payload строится без вызова транспорта. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + action_type_id: идентификатор целевого действия. + budget_penny: бюджет в копейках. + budget_type: тип бюджета кампании. + item_id: идентификатор объявления. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ resolved_item_id = item_id or self._require_item_id() @@ -574,12 +915,23 @@ def update_auto( target=target, request_payload=request_payload, ) - 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, + payload = self._execute( + UPDATE_TARGET_ACTION_AUTO, + request=UpdateAutoBidRequest( + item_id=resolved_item_id, + action_type_id=action_type_id, + budget_penny=budget_penny, + budget_type=budget_type, + ), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="update_auto", + target=target, + request_payload=request_payload, ) @swagger_operation( @@ -598,14 +950,31 @@ def update_manual( item_id: int | None = None, dry_run: bool = False, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> PromotionActionResult: """Применяет ручную настройку. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. - - При `dry_run=True` payload строится без вызова транспорта. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + action_type_id: идентификатор целевого действия. + bid_penny: ставка в копейках. + limit_penny: лимит расходов в копейках. + item_id: идентификатор объявления. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ resolved_item_id = item_id or self._require_item_id() @@ -627,12 +996,23 @@ def update_manual( target=target, request_payload=request_payload, ) - 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, + payload = self._execute( + UPDATE_TARGET_ACTION_MANUAL, + request=UpdateManualBidRequest( + item_id=resolved_item_id, + action_type_id=action_type_id, + bid_penny=bid_penny, + limit_penny=limit_penny, + ), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="update_manual", + target=target, + request_payload=request_payload, ) def _require_item_id(self) -> int: @@ -666,19 +1046,41 @@ def create_budget( start_time: datetime | None = None, finish_time: datetime | None = None, items: list[int] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> AutostrategyBudget: """Рассчитывает бюджет кампании. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + campaign_type: тип автостратегии или рекламной кампании. + start_time: дата и время начала кампании. + finish_time: дата и время окончания кампании. + items: элементы запроса с объявлениями, ставками или настройками продвижения. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutostrategyBudget` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ _validate_optional_datetime("start_time", start_time) _validate_optional_datetime("finish_time", finish_time) - return AutostrategyClient(self.transport).create_budget( - campaign_type=campaign_type, - start_time=start_time, - finish_time=finish_time, - items=items, + return self._execute( + CREATE_AUTOSTRATEGY_BUDGET, + request=CreateAutostrategyBudgetRequest( + campaign_type=campaign_type, + start_time=start_time, + finish_time=finish_time, + items=items, + ), + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -702,28 +1104,56 @@ def create( items: list[int] | None = None, start_time: datetime | None = None, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> CampaignActionResult: """Создает новую кампанию. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + campaign_type: тип автостратегии или рекламной кампании. + title: название кампании. + budget: бюджет кампании. + budget_bonus: бонусный бюджет кампании. + budget_real: реальный бюджет кампании. + calc_id: идентификатор расчета или прогноза кампании. + description: описание кампании. + finish_time: дата и время окончания кампании. + items: элементы запроса с объявлениями, ставками или настройками продвижения. + start_time: дата и время начала кампании. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CampaignActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ _validate_optional_datetime("start_time", start_time) _validate_optional_datetime("finish_time", finish_time) - return AutostrategyClient(self.transport).create_campaign( - 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, + return self._execute( + CREATE_AUTOSTRATEGY_CAMPAIGN, + request=CreateAutostrategyCampaignRequest( + campaign_type=campaign_type, + title=title, + budget=budget, + budget_bonus=budget_bonus, + budget_real=budget_real, + calc_id=calc_id, + description=description, + finish_time=finish_time, + items=items, + start_time=start_time, + ), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -731,7 +1161,7 @@ def create( "/autostrategy/v1/campaign/edit", spec="Автостратегия.json", operation_id="editAutostrategyCampaign", - method_args={"version": "body.version"}, + method_args={"campaign_id": "body.campaignId", "version": "body.version"}, ) def update( self, @@ -746,27 +1176,54 @@ def update( start_time: datetime | None = None, title: str | None = None, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> CampaignActionResult: """Редактирует кампанию. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + version: версия кампании для optimistic locking или согласованного обновления. + campaign_id: идентификатор кампании. + budget: бюджет кампании. + calc_id: идентификатор расчета или прогноза кампании. + description: описание кампании. + finish_time: дата и время окончания кампании. + items: элементы запроса с объявлениями, ставками или настройками продвижения. + start_time: дата и время начала кампании. + title: название кампании. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CampaignActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ _validate_optional_datetime("start_time", start_time) _validate_optional_datetime("finish_time", finish_time) - return AutostrategyClient(self.transport).edit_campaign( - 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, + return self._execute( + UPDATE_AUTOSTRATEGY_CAMPAIGN, + request=UpdateAutostrategyCampaignRequest( + campaign_id=campaign_id or self._require_campaign_id(), + version=version, + budget=budget, + calc_id=calc_id, + description=description, + finish_time=finish_time, + items=items, + start_time=start_time, + title=title, + ), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -776,14 +1233,37 @@ def update( operation_id="getAutostrategyCampaignInfo", method_args={"campaign_id": "body.campaign_id"}, ) - def get(self, *, campaign_id: int | None = None) -> CampaignDetailsResult: + def get( + self, + *, + campaign_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CampaignDetailsResult: """Получает полную информацию о кампании. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + campaign_id: идентификатор кампании. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CampaignDetailsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return AutostrategyClient(self.transport).get_campaign_info( - campaign_id=campaign_id or self._require_campaign_id() + return self._execute( + GET_AUTOSTRATEGY_CAMPAIGN, + request=GetAutostrategyCampaignInfoRequest( + campaign_id=campaign_id or self._require_campaign_id() + ), + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -791,7 +1271,7 @@ def get(self, *, campaign_id: int | None = None) -> CampaignDetailsResult: "/autostrategy/v1/campaign/stop", spec="Автостратегия.json", operation_id="stopAutostrategyCampaign", - method_args={"version": "body.version"}, + method_args={"campaign_id": "body.campaignId", "version": "body.version"}, ) def delete( self, @@ -799,18 +1279,38 @@ def delete( version: int, campaign_id: int | None = None, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> CampaignActionResult: """Останавливает кампанию. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Аргументы: + version: версия кампании для optimistic locking или согласованного обновления. + campaign_id: идентификатор кампании. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CampaignActionResult` с типизированными данными ответа. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return AutostrategyClient(self.transport).stop_campaign( - campaign_id=campaign_id or self._require_campaign_id(), - version=version, + return self._execute( + DELETE_AUTOSTRATEGY_CAMPAIGN, + request=StopAutostrategyCampaignRequest( + campaign_id=campaign_id or self._require_campaign_id(), + version=version, + ), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -828,12 +1328,30 @@ def list( order_by: builtins.list[tuple[str, str]] | None = None, updated_from: datetime | None = None, updated_to: datetime | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> CampaignsResult: - """Получает список кампаний. - - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + """Возвращает кампании автостратегии с фильтрами и пагинацией. + + Аргументы: + limit: ограничивает размер возвращаемой выборки. + offset: задает смещение первой записи в выборке. + status_id: фильтрует результат по числовому статусу. + order_by: задает порядок сортировки результата. + updated_from: фильтрует записи, обновленные не раньше указанного времени. + updated_to: фильтрует записи, обновленные не позже указанного времени. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CampaignsResult` с типизированными данными ответа API. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ filter_payload = ( @@ -851,12 +1369,17 @@ def list( if order_by is not None else None ) - return AutostrategyClient(self.transport).list_campaigns( - limit=limit, - offset=offset, - status_id=status_id, - order_by=order_by_payload, - filter=filter_payload, + return self._execute( + LIST_AUTOSTRATEGY_CAMPAIGNS, + request=ListAutostrategyCampaignsRequest( + limit=limit, + offset=offset, + status_id=status_id, + order_by=order_by_payload, + filter=filter_payload, + ), + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -865,14 +1388,37 @@ def list( spec="Автостратегия.json", operation_id="getAutostrategyStat", ) - def get_stat(self, *, campaign_id: int | None = None) -> AutostrategyStat: + def get_stat( + self, + *, + campaign_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutostrategyStat: """Получает статистику кампании. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + campaign_id: идентификатор кампании. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutostrategyStat` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return AutostrategyClient(self.transport).get_stat( - campaign_id=campaign_id or self._require_campaign_id() + return self._execute( + GET_AUTOSTRATEGY_STAT, + request=GetAutostrategyStatRequest( + campaign_id=campaign_id or self._require_campaign_id() + ), + timeout=timeout, + retry=retry, ) def _require_campaign_id(self) -> int: diff --git a/avito/promotion/enums.py b/avito/promotion/enums.py deleted file mode 100644 index 5e626c2..0000000 --- a/avito/promotion/enums.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Enum-значения раздела promotion.""" - -from __future__ import annotations - -from enum import Enum - - -class PromotionStatus(str, Enum): - """Статус promotion-объекта или операции.""" - - UNKNOWN = "__unknown__" - UPSTREAM_UNKNOWN = "unknown" - AVAILABLE = "available" - ACTIVE = "active" - CREATED = "created" - INITIALIZED = "initialized" - WAITING = "waiting" - IN_PROCESS = "in_process" - PROCESSED = "processed" - CANCELED = "canceled" - ERROR = "error" - REMOVED = "removed" - AUTO = "auto" - MANUAL = "manual" - APPLIED = "applied" - PARTIAL = "partial" - FAILED = "failed" - PREVIEW = "preview" - - -class PromotionOrderStatus(str, Enum): - """Статус заявки на продвижение.""" - - UNKNOWN = "__unknown__" - UPSTREAM_UNKNOWN = "unknown" - APPLIED = "applied" - CREATED = "created" - AUTO = "auto" - MANUAL = "manual" - PARTIAL = "partial" - INITIALIZED = "initialized" - WAITING = "waiting" - IN_PROCESS = "in_process" - PROCESSED = "processed" - - -class PromotionOrderServiceStatus(str, Enum): - """Статус услуги внутри заявки на продвижение.""" - - UNKNOWN = "__unknown__" - UPSTREAM_UNKNOWN = "unknown" - AVAILABLE = "available" - ACTIVE = "active" - ERROR = "error" - CANCELED = "canceled" - PROCESSED = "processed" - - -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", - "PromotionOrderServiceStatus", - "PromotionOrderStatus", - "PromotionStatus", - "TargetActionBudgetType", - "TargetActionSelectedType", -) diff --git a/avito/promotion/mappers.py b/avito/promotion/mappers.py deleted file mode 100644 index c0cbad7..0000000 --- a/avito/promotion/mappers.py +++ /dev/null @@ -1,739 +0,0 @@ -"""Мапперы JSON -> dataclass для пакета promotion.""" - -from __future__ import annotations - -from collections.abc import Mapping -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, - PromotionOrderServiceStatus, - PromotionOrderStatus, - PromotionStatus, - TargetActionBudgetType, - TargetActionSelectedType, -) -from avito.promotion.models import ( - AutostrategyBudget, - AutostrategyBudgetPoint, - AutostrategyPriceRange, - AutostrategyStat, - AutostrategyStatItem, - AutostrategyStatTotals, - BbipBudgetOption, - BbipDurationRange, - BbipForecastsResult, - BbipSuggest, - BbipSuggestsResult, - CampaignActionResult, - CampaignDetailsResult, - CampaignForecast, - CampaignForecastRange, - CampaignInfo, - CampaignItem, - CampaignsResult, - CpaAuctionBidOption, - CpaAuctionBidsResult, - CpaAuctionItemBid, - PromotionActionItem, - PromotionActionResult, - PromotionForecast, - PromotionOrderError, - PromotionOrderInfo, - PromotionOrdersResult, - PromotionOrderStatusItem, - PromotionOrderStatusResult, - PromotionService, - PromotionServiceDictionary, - PromotionServicesResult, - PromotionServiceType, - TargetActionAutoBids, - TargetActionAutoPromotion, - TargetActionBid, - TargetActionBudget, - TargetActionGetBidsResult, - TargetActionManualBids, - TargetActionManualPromotion, - TargetActionPromotion, - TargetActionPromotionsByItemIdsResult, - TrxCommissionInfo, - TrxCommissionRange, - TrxCommissionsResult, -) - -Payload = Mapping[str, object] - - -def _expect_mapping(payload: object) -> Payload: - if not isinstance(payload, Mapping): - raise ResponseMappingError("Ожидался JSON-объект.", payload=payload) - return cast(Payload, payload) - - -def _list(payload: Payload, *keys: str) -> list[Payload]: - for key in keys: - value = payload.get(key) - if isinstance(value, list): - return [item for item in value if isinstance(item, Mapping)] - return [] - - -def _mapping(payload: Payload, *keys: str) -> Payload: - for key in keys: - value = payload.get(key) - if isinstance(value, Mapping): - return cast(Payload, value) - return {} - - -def _str(payload: Payload, *keys: str) -> str | None: - for key in keys: - value = payload.get(key) - if isinstance(value, str): - return value - return None - - -def _int(payload: Payload, *keys: str) -> int | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - continue - if isinstance(value, int): - return value - return None - - -def _bool(payload: Payload, *keys: str) -> bool | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - return value - return None - - -def _datetime(payload: Payload, *keys: str) -> datetime | None: - for key in keys: - value = payload.get(key) - if isinstance(value, str): - try: - return datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError: - continue - return None - - -def _items_payload(payload: Payload) -> list[Payload]: - return _list(payload, "items", "result", "services", "orders", "campaigns") - - -def map_promotion_service_dictionary(payload: object) -> PromotionServiceDictionary: - """Преобразует словарь услуг продвижения.""" - - data = _expect_mapping(payload) - return PromotionServiceDictionary( - items=[ - PromotionServiceType( - code=_str(item, "code", "serviceCode", "id"), - title=_str(item, "title", "name", "description"), - ) - for item in _items_payload(data) - ], - ) - - -def map_promotion_services(payload: object) -> PromotionServicesResult: - """Преобразует список услуг продвижения.""" - - data = _expect_mapping(payload) - return PromotionServicesResult( - items=[ - PromotionService( - item_id=_int(item, "itemId", "itemID"), - service_code=_str(item, "serviceCode", "code"), - service_name=_str(item, "serviceName", "name", "title"), - price=_int(item, "price", "pricePenny"), - status=map_enum_or_unknown( - _str(item, "status"), - PromotionOrderServiceStatus, - enum_name="promotion.order_service_status", - ), - ) - for item in _items_payload(data) - ], - ) - - -def map_promotion_orders(payload: object) -> PromotionOrdersResult: - """Преобразует список заявок на продвижение.""" - - data = _expect_mapping(payload) - return PromotionOrdersResult( - items=[ - PromotionOrderInfo( - order_id=_str(item, "orderId", "orderID", "id"), - item_id=_int(item, "itemId", "itemID"), - service_code=_str(item, "serviceCode", "code"), - status=map_enum_or_unknown( - _str(item, "status"), - PromotionOrderStatus, - enum_name="promotion.order_status", - ), - created_at=_datetime(item, "createdAt", "created_at"), - ) - for item in _items_payload(data) - ], - ) - - -def map_promotion_order_status(payload: object) -> PromotionOrderStatusResult: - """Преобразует documented shape статуса заявки на продвижение.""" - - data = _expect_mapping(payload) - order_id = _str(data, "orderId", "orderID", "id") - status = map_enum_or_unknown( - _str(data, "status"), - PromotionOrderStatus, - enum_name="promotion.order_status", - ) - if order_id is None or status is None: - raise ResponseMappingError( - "Статус заявки promotion должен содержать `orderId` и `status`.", - payload=payload, - ) - errors_payload = data.get("errors", []) - if errors_payload is not None and not isinstance(errors_payload, list): - raise ResponseMappingError("Поле `errors` должно быть массивом.", payload=payload) - return PromotionOrderStatusResult( - order_id=order_id, - status=status, - total_price=_int(data, "totalPrice"), - items=[ - PromotionOrderStatusItem( - item_id=_int(item, "itemId", "itemID"), - price=_int(item, "price"), - slug=_str(item, "slug"), - status=map_enum_or_unknown( - _str(item, "status"), - PromotionOrderServiceStatus, - enum_name="promotion.order_service_status", - ), - error_reason=_str(item, "errorReason"), - ) - for item in _list(data, "items") - ], - errors=[ - PromotionOrderError( - item_id=_int(item, "itemId", "itemID"), - error_code=_int(item, "errorCode"), - error_text=_str(item, "errorText"), - ) - for item in errors_payload or [] - if isinstance(item, Mapping) - ], - ) - - -def map_bbip_forecasts(payload: object) -> BbipForecastsResult: - """Преобразует прогнозы BBIP.""" - - data = _expect_mapping(payload) - return BbipForecastsResult( - items=[ - PromotionForecast( - item_id=_int(item, "itemId", "itemID"), - min_views=_int(item, "min"), - max_views=_int(item, "max"), - total_price=_int(item, "totalPrice"), - total_old_price=_int(item, "totalOldPrice"), - ) - for item in _items_payload(data) - ], - ) - - -def map_promotion_action( - payload: object, - *, - action: str, - target: Mapping[str, object] | None, - request_payload: Mapping[str, object], -) -> PromotionActionResult: - """Преобразует результат действия по продвижению.""" - - data = _expect_mapping(payload) - items_payload = _items_payload(data) - if not items_payload: - success_payload = _mapping(data, "success") - items_payload = _list(success_payload, "items", "result") - items = [ - PromotionActionItem( - item_id=_int(item, "itemId", "itemID"), - success=bool(item.get("success", True)), - status=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 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] = {} - if items: - details["items"] = [ - { - "item_id": item.item_id, - "success": item.success, - "status": item.status, - "message": item.message, - } - for item in items - ] - elif message := _str(data, "message", "status"): - details["message"] = message - return PromotionActionResult( - action=action, - target=dict(target) if target is not None else None, - status=resolved_status, - applied=applied, - request_payload=dict(request_payload), - warnings=messages if not applied else [], - upstream_reference=_extract_upstream_reference(data, items), - details=details, - ) - - -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 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 PromotionStatus.APPLIED if applied else PromotionStatus.FAILED - - -def _extract_upstream_reference( - payload: Payload, - items: list[PromotionActionItem], -) -> str | None: - reference = _str(payload, "orderId", "requestId", "promotionId", "id") - if reference is not None: - return reference - for item in items: - if item.upstream_reference is not None: - return item.upstream_reference - return None - - -def map_bbip_suggests(payload: object) -> BbipSuggestsResult: - """Преобразует варианты бюджета BBIP.""" - - data = _expect_mapping(payload) - return BbipSuggestsResult( - items=[ - BbipSuggest( - item_id=_int(item, "itemId", "itemID"), - duration=_map_bbip_duration(_mapping(item, "duration")), - budgets=[_map_bbip_budget(option) for option in _list(item, "budgets")], - ) - for item in _items_payload(data) - ], - ) - - -def _map_bbip_budget(payload: Payload) -> BbipBudgetOption: - return BbipBudgetOption( - price=_int(payload, "price"), - old_price=_int(payload, "oldPrice"), - is_recommended=_bool(payload, "isRecommended"), - ) - - -def _map_bbip_duration(payload: Payload) -> BbipDurationRange | None: - if not payload: - return None - return BbipDurationRange( - start=_int(payload, "from"), - stop=_int(payload, "to"), - recommended=_int(payload, "recommended"), - ) - - -def map_trx_commissions(payload: object) -> TrxCommissionsResult: - """Преобразует комиссии TrxPromo.""" - - data = _expect_mapping(payload) - items_payload = _items_payload(data) - if not items_payload: - success_payload = _mapping(data, "success") - items_payload = _list(success_payload, "items", "result") - return TrxCommissionsResult( - items=[ - TrxCommissionInfo( - item_id=_int(item, "itemId", "itemID"), - commission=_int(item, "commission"), - is_active=_bool(item, "isActive", "active"), - valid_commission_range=_map_trx_range(_mapping(item, "validCommissionRange")), - ) - for item in items_payload - ], - ) - - -def _map_trx_range(payload: Payload) -> TrxCommissionRange | None: - if not payload: - return None - return TrxCommissionRange( - value_min=_int(payload, "valueMin"), - value_max=_int(payload, "valueMax"), - step=_int(payload, "step"), - ) - - -def map_cpa_auction_bids(payload: object) -> CpaAuctionBidsResult: - """Преобразует действующие и доступные ставки CPA-аукциона.""" - - data = _expect_mapping(payload) - return CpaAuctionBidsResult( - items=[ - CpaAuctionItemBid( - item_id=_int(item, "itemId", "itemID"), - price_penny=_int(item, "pricePenny"), - expiration_time=_datetime(item, "expirationTime"), - available_prices=[ - CpaAuctionBidOption( - price_penny=_int(option, "pricePenny"), - goodness=_int(option, "goodness"), - ) - for option in _list(item, "availablePrices") - ], - ) - for item in _items_payload(data) - ], - ) - - -def _map_target_action_bid(item: Payload) -> TargetActionBid: - return TargetActionBid( - value_penny=_int(item, "valuePenny"), - min_forecast=_int(item, "minForecast"), - max_forecast=_int(item, "maxForecast"), - compare=_int(item, "compare"), - ) - - -def _map_target_action_budget(item: Payload) -> TargetActionBudget: - return TargetActionBudget( - budget_penny=_int(item, "valuePenny"), - min_forecast=_int(item, "minForecast"), - max_forecast=_int(item, "maxForecast"), - compare=_int(item, "compare"), - ) - - -def _map_target_action_manual(payload: Payload) -> TargetActionManualBids: - bids_payload = payload.get("bids") - if bids_payload is not None and not isinstance(bids_payload, list): - raise ResponseMappingError("Поле `manual.bids` должно быть массивом.", payload=payload) - return TargetActionManualBids( - bid_penny=_int(payload, "bidPenny"), - limit_penny=_int(payload, "limitPenny"), - rec_bid_penny=_int(payload, "recBidPenny"), - min_bid_penny=_int(payload, "minBidPenny"), - max_bid_penny=_int(payload, "maxBidPenny"), - min_limit_penny=_int(payload, "minLimitPenny"), - max_limit_penny=_int(payload, "maxLimitPenny"), - bids=[ - _map_target_action_bid(item) for item in bids_payload or [] if isinstance(item, Mapping) - ], - ) - - -def _map_budget_values(payload: Payload, key: str) -> list[TargetActionBudget]: - budget = payload.get(key) - if budget is None: - return [] - if not isinstance(budget, Mapping): - raise ResponseMappingError(f"Поле `{key}` должно быть объектом.", payload=payload) - values = budget.get("budgets") - if values is not None and not isinstance(values, list): - raise ResponseMappingError(f"Поле `{key}.budgets` должно быть массивом.", payload=payload) - return [_map_target_action_budget(item) for item in values or [] if isinstance(item, Mapping)] - - -def _map_target_action_auto(payload: Payload) -> TargetActionAutoBids: - return TargetActionAutoBids( - budget_penny=_int(payload, "budgetPenny"), - budget_type=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"), - weekly_budget=_map_budget_values(payload, "weeklyBudget"), - monthly_budget=_map_budget_values(payload, "monthlyBudget"), - ) - - -def map_target_action_get_bids_out(payload: object) -> TargetActionGetBidsResult: - """Преобразует documented shape GET /cpxpromo/1/getBids/{itemId}.""" - - data = _expect_mapping(payload) - action_type_id = _int(data, "actionTypeID") - 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`.", - payload=payload, - ) - return TargetActionGetBidsResult( - action_type_id=action_type_id, - selected_type=selected_type, - auto=( - _map_target_action_auto(cast(Payload, data["auto"])) - if isinstance(data.get("auto"), Mapping) - else None - ), - manual=( - _map_target_action_manual(cast(Payload, data["manual"])) - if isinstance(data.get("manual"), Mapping) - else None - ), - ) - - -def map_target_action_get_promotions_by_item_ids_out( - payload: object, -) -> TargetActionPromotionsByItemIdsResult: - """Преобразует documented shape POST /cpxpromo/1/getPromotionsByItemIds.""" - - data = _expect_mapping(payload) - items_payload = data.get("items") - if not isinstance(items_payload, list): - raise ResponseMappingError( - "Ответ getPromotionsByItemIds должен содержать массив `items`.", payload=payload - ) - items: list[TargetActionPromotion] = [] - for item in items_payload: - if not isinstance(item, Mapping): - continue - item_id = _int(item, "itemID") - action_type_id = _int(item, "actionTypeID") - if item_id is None or action_type_id is None: - raise ResponseMappingError( - "Элемент getPromotionsByItemIds должен содержать `itemID` и `actionTypeID`.", - payload=item, - ) - items.append( - TargetActionPromotion( - item_id=item_id, - action_type_id=action_type_id, - auto=( - TargetActionAutoPromotion( - budget_penny=_int(cast(Payload, item["autoPromotion"]), "budgetPenny"), - 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 - ), - manual=( - TargetActionManualPromotion( - bid_penny=_int(cast(Payload, item["manualPromotion"]), "bidPenny"), - limit_penny=_int(cast(Payload, item["manualPromotion"]), "limitPenny"), - ) - if isinstance(item.get("manualPromotion"), Mapping) - else None - ), - ) - ) - return TargetActionPromotionsByItemIdsResult( - items=items, - ) - - -def map_autostrategy_budget(payload: object) -> AutostrategyBudget: - """Преобразует расчет бюджета автокампании.""" - - data = _expect_mapping(payload) - source = _mapping(data, "budget") - return AutostrategyBudget( - calc_id=_int(data, "calcId"), - recommended=_map_budget_point(_mapping(source, "recommended")), - minimal=_map_budget_point(_mapping(source, "minimal")), - maximal=_map_budget_point(_mapping(source, "maximal")), - price_ranges=[_map_price_range(item) for item in _list(source, "priceRanges")], - ) - - -def _map_budget_point(payload: Payload) -> AutostrategyBudgetPoint | None: - if not payload: - return None - return AutostrategyBudgetPoint( - total=_int(payload, "total"), - real=_int(payload, "real"), - bonus=_int(payload, "bonus"), - calls_from=_int(payload, "callsFrom"), - calls_to=_int(payload, "callsTo"), - views_from=_int(payload, "viewsFrom"), - views_to=_int(payload, "viewsTo"), - ) - - -def _map_price_range(payload: Payload) -> AutostrategyPriceRange: - return AutostrategyPriceRange( - price_from=_int(payload, "priceFrom"), - price_to=_int(payload, "priceTo"), - percent=_int(payload, "percent"), - calls_from=_int(payload, "callsFrom"), - calls_to=_int(payload, "callsTo"), - views_from=_int(payload, "viewsFrom"), - views_to=_int(payload, "viewsTo"), - ) - - -def map_campaign_action(payload: object) -> CampaignActionResult: - """Преобразует результат операции с автокампанией.""" - - data = _expect_mapping(payload) - return CampaignActionResult(campaign=_map_campaign(_mapping(data, "campaign"))) - - -def _map_campaign(payload: Payload) -> CampaignInfo | None: - if not payload: - return None - return CampaignInfo( - campaign_id=_int(payload, "campaignId"), - campaign_type=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"), - description=_str(payload, "description"), - finish_time=_datetime(payload, "finishTime"), - items_count=_int(payload, "itemsCount"), - start_time=_datetime(payload, "startTime"), - status_id=_int(payload, "statusId"), - title=_str(payload, "title"), - update_time=_datetime(payload, "updateTime"), - user_id=_int(payload, "userId"), - version=_int(payload, "version"), - ) - - -def map_campaign_info(payload: object) -> CampaignDetailsResult: - """Преобразует полную информацию об автокампании.""" - - data = _expect_mapping(payload) - return CampaignDetailsResult( - campaign=_map_campaign(_mapping(data, "campaign")), - forecast=_map_campaign_forecast(_mapping(data, "forecast")), - items=[_map_campaign_item(item) for item in _list(data, "items")], - ) - - -def _map_campaign_forecast(payload: Payload) -> CampaignForecast | None: - if not payload: - return None - return CampaignForecast( - calls=_map_campaign_forecast_range(_mapping(payload, "calls")), - views=_map_campaign_forecast_range(_mapping(payload, "views")), - ) - - -def _map_campaign_forecast_range(payload: Payload) -> CampaignForecastRange | None: - if not payload: - return None - return CampaignForecastRange( - from_value=_int(payload, "from"), - to_value=_int(payload, "to"), - ) - - -def _map_campaign_item(payload: Payload) -> CampaignItem: - return CampaignItem( - item_id=_int(payload, "itemId"), - is_active=_bool(payload, "isActive"), - ) - - -def map_campaign_list_item(payload: object) -> CampaignInfo: - """Преобразует элемент списка автокампаний.""" - - data = _expect_mapping(payload) - campaign = _map_campaign(data) - if campaign is None: - raise ResponseMappingError("Не удалось смэппить кампанию.", payload=payload) - return campaign - - -def map_campaigns(payload: object) -> CampaignsResult: - """Преобразует список автокампаний.""" - - data = _expect_mapping(payload) - return CampaignsResult( - items=[map_campaign_list_item(item) for item in _list(data, "campaigns")], - total_count=_int(data, "totalCount"), - ) - - -def map_autostrategy_stat(payload: object) -> AutostrategyStat: - """Преобразует статистику автокампании.""" - - data = _expect_mapping(payload) - return AutostrategyStat( - items=[_map_autostrategy_stat_item(item) for item in _list(data, "stat")], - totals=_map_autostrategy_stat_totals(_mapping(data, "totals")), - ) - - -def _map_autostrategy_stat_item(payload: Payload) -> AutostrategyStatItem: - return AutostrategyStatItem( - date=_datetime(payload, "date"), - calls=_int(payload, "calls"), - views=_int(payload, "views"), - calls_forecast=_map_campaign_forecast_range(_mapping(payload, "callsForecast")), - views_forecast=_map_campaign_forecast_range(_mapping(payload, "viewsForecast")), - ) - - -def _map_autostrategy_stat_totals(payload: Payload) -> AutostrategyStatTotals | None: - if not payload: - return None - return AutostrategyStatTotals( - calls=_int(payload, "calls"), - views=_int(payload, "views"), - ) diff --git a/avito/promotion/models.py b/avito/promotion/models.py index b9f109f..012f0a6 100644 --- a/avito/promotion/models.py +++ b/avito/promotion/models.py @@ -2,19 +2,156 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass, field from datetime import datetime -from typing import TypedDict +from enum import Enum +from typing import cast +from avito.core.enums import map_enum_or_unknown +from avito.core.exceptions import ResponseMappingError from avito.core.serialization import SerializableModel -from avito.promotion.enums import ( - CampaignType, - PromotionOrderServiceStatus, - PromotionOrderStatus, - PromotionStatus, - TargetActionBudgetType, - TargetActionSelectedType, -) + + +class PromotionStatus(str, Enum): + """Статус promotion-объекта или операции.""" + + UNKNOWN = "__unknown__" + UPSTREAM_UNKNOWN = "unknown" + AVAILABLE = "available" + ACTIVE = "active" + CREATED = "created" + INITIALIZED = "initialized" + WAITING = "waiting" + IN_PROCESS = "in_process" + PROCESSED = "processed" + CANCELED = "canceled" + ERROR = "error" + REMOVED = "removed" + AUTO = "auto" + MANUAL = "manual" + APPLIED = "applied" + PARTIAL = "partial" + FAILED = "failed" + PREVIEW = "preview" + + +class PromotionOrderStatus(str, Enum): + """Статус заявки на продвижение.""" + + UNKNOWN = "__unknown__" + UPSTREAM_UNKNOWN = "unknown" + APPLIED = "applied" + CREATED = "created" + AUTO = "auto" + MANUAL = "manual" + PARTIAL = "partial" + INITIALIZED = "initialized" + WAITING = "waiting" + IN_PROCESS = "in_process" + PROCESSED = "processed" + + +class PromotionOrderServiceStatus(str, Enum): + """Статус услуги внутри заявки на продвижение.""" + + UNKNOWN = "__unknown__" + UPSTREAM_UNKNOWN = "unknown" + AVAILABLE = "available" + ACTIVE = "active" + ERROR = "error" + CANCELED = "canceled" + PROCESSED = "processed" + + +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" + + +_Payload = Mapping[str, object] + + +def _expect_mapping(payload: object) -> _Payload: + if not isinstance(payload, Mapping): + raise ResponseMappingError("Ожидался JSON-объект.", payload=payload) + return cast(_Payload, payload) + + +def _list(payload: _Payload, *keys: str) -> list[_Payload]: + for key in keys: + value = payload.get(key) + if isinstance(value, list): + return [item for item in value if isinstance(item, Mapping)] + return [] + + +def _mapping(payload: _Payload, *keys: str) -> _Payload: + for key in keys: + value = payload.get(key) + if isinstance(value, Mapping): + return cast(_Payload, value) + return {} + + +def _str(payload: _Payload, *keys: str) -> str | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str): + return value + return None + + +def _int(payload: _Payload, *keys: str) -> int | None: + for key in keys: + value = payload.get(key) + if isinstance(value, bool): + continue + if isinstance(value, int): + return value + return None + + +def _bool(payload: _Payload, *keys: str) -> bool | None: + for key in keys: + value = payload.get(key) + if isinstance(value, bool): + return value + return None + + +def _datetime(payload: _Payload, *keys: str) -> datetime | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str): + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + continue + return None + + +def _items_payload(payload: _Payload) -> list[_Payload]: + return _list(payload, "items", "result", "services", "orders", "campaigns") @dataclass(slots=True, frozen=True) @@ -31,6 +168,25 @@ class PromotionServiceDictionary(SerializableModel): items: list[PromotionServiceType] + @classmethod + def from_payload(cls, payload: object) -> PromotionServiceDictionary: + """Преобразует словарь услуг продвижения.""" + + if isinstance(payload, list): + items_payload = [item for item in payload if isinstance(item, Mapping)] + else: + data = _expect_mapping(payload) + items_payload = _items_payload(data) + return cls( + items=[ + PromotionServiceType( + code=_str(item, "code", "serviceCode", "id", "slug"), + title=_str(item, "title", "name", "description"), + ) + for item in items_payload + ], + ) + @dataclass(slots=True, frozen=True) class ListPromotionServicesRequest: @@ -61,6 +217,28 @@ class PromotionServicesResult(SerializableModel): items: list[PromotionService] + @classmethod + def from_payload(cls, payload: object) -> PromotionServicesResult: + """Преобразует список услуг продвижения.""" + + data = _expect_mapping(payload) + return cls( + items=[ + PromotionService( + item_id=_int(item, "itemId", "itemID"), + service_code=_str(item, "serviceCode", "code"), + service_name=_str(item, "serviceName", "name", "title"), + price=_int(item, "price", "pricePenny"), + status=map_enum_or_unknown( + _str(item, "status"), + PromotionOrderServiceStatus, + enum_name="promotion.order_service_status", + ), + ) + for item in _items_payload(data) + ], + ) + @dataclass(slots=True, frozen=True) class ListPromotionOrdersRequest: @@ -72,12 +250,7 @@ class ListPromotionOrdersRequest: def to_payload(self) -> dict[str, object]: """Сериализует запрос списка заявок.""" - payload: dict[str, object] = {} - if self.item_ids is not None: - payload["itemIds"] = self.item_ids - if self.order_ids is not None: - payload["orderIds"] = self.order_ids - return payload + return {} @dataclass(slots=True, frozen=True) @@ -97,6 +270,28 @@ class PromotionOrdersResult(SerializableModel): items: list[PromotionOrderInfo] + @classmethod + def from_payload(cls, payload: object) -> PromotionOrdersResult: + """Преобразует список заявок на продвижение.""" + + data = _expect_mapping(payload) + return cls( + items=[ + PromotionOrderInfo( + order_id=_str(item, "orderId", "orderID", "id"), + item_id=_int(item, "itemId", "itemID"), + service_code=_str(item, "serviceCode", "code"), + status=map_enum_or_unknown( + _str(item, "status"), + PromotionOrderStatus, + enum_name="promotion.order_status", + ), + created_at=_datetime(item, "createdAt", "created_at"), + ) + for item in _items_payload(data) + ], + ) + @dataclass(slots=True, frozen=True) class GetPromotionOrderStatusRequest: @@ -107,7 +302,7 @@ class GetPromotionOrderStatusRequest: def to_payload(self) -> dict[str, object]: """Сериализует запрос статуса заявок.""" - return {"orderIds": self.order_ids} + return {"orderId": self.order_ids[0]} @dataclass(slots=True, frozen=True) @@ -140,35 +335,53 @@ class PromotionOrderStatusResult(SerializableModel): items: list[PromotionOrderStatusItem] errors: list[PromotionOrderError] - -class BbipItemInput(TypedDict): - """Входные параметры одного объявления для BBIP-методов.""" - - item_id: int - duration: int - price: int - old_price: int - - -class _TrxItemInputRequired(TypedDict): - """Обязательные поля входных параметров TrxPromo.""" - - item_id: int - commission: int - date_from: datetime - - -class TrxItemInput(_TrxItemInputRequired, total=False): - """Входные параметры одного объявления для TrxPromo-методов.""" - - date_to: datetime | None - - -class BidItemInput(TypedDict): - """Входные параметры одной ставки CPA-аукциона.""" - - item_id: int - price_penny: int + @classmethod + def from_payload(cls, payload: object) -> PromotionOrderStatusResult: + """Преобразует documented shape статуса заявки на продвижение.""" + + data = _expect_mapping(payload) + order_id = _str(data, "orderId", "orderID", "id") + status = map_enum_or_unknown( + _str(data, "status"), + PromotionOrderStatus, + enum_name="promotion.order_status", + ) + if order_id is None or status is None: + raise ResponseMappingError( + "Статус заявки promotion должен содержать `orderId` и `status`.", + payload=payload, + ) + errors_payload = data.get("errors", []) + if errors_payload is not None and not isinstance(errors_payload, list): + raise ResponseMappingError("Поле `errors` должно быть массивом.", payload=payload) + return cls( + order_id=order_id, + status=status, + total_price=_int(data, "totalPrice"), + items=[ + PromotionOrderStatusItem( + item_id=_int(item, "itemId", "itemID"), + price=_int(item, "price"), + slug=_str(item, "slug"), + status=map_enum_or_unknown( + _str(item, "status"), + PromotionOrderServiceStatus, + enum_name="promotion.order_service_status", + ), + error_reason=_str(item, "errorReason"), + ) + for item in _list(data, "items") + ], + errors=[ + PromotionOrderError( + item_id=_int(item, "itemId", "itemID"), + error_code=_int(item, "errorCode"), + error_text=_str(item, "errorText"), + ) + for item in errors_payload or [] + if isinstance(item, Mapping) + ], + ) @dataclass(slots=True, frozen=True) @@ -220,6 +433,24 @@ class BbipForecastsResult(SerializableModel): items: list[PromotionForecast] + @classmethod + def from_payload(cls, payload: object) -> BbipForecastsResult: + """Преобразует прогнозы BBIP.""" + + data = _expect_mapping(payload) + return cls( + items=[ + PromotionForecast( + item_id=_int(item, "itemId", "itemID"), + min_views=_int(item, "min"), + max_views=_int(item, "max"), + total_price=_int(item, "totalPrice"), + total_old_price=_int(item, "totalOldPrice"), + ) + for item in _items_payload(data) + ], + ) + @dataclass(slots=True, frozen=True) class CreateBbipOrderRequest: @@ -254,6 +485,19 @@ class PromotionActionItem(SerializableModel): upstream_reference: str | None = None +@dataclass(slots=True, frozen=True) +class PromotionActionPayload(SerializableModel): + """Raw upstream payload for promotion write operations enriched by domain methods.""" + + raw_payload: Mapping[str, object] + + @classmethod + def from_payload(cls, payload: object) -> PromotionActionPayload: + """Preserve upstream action payload for domain-level normalization.""" + + return cls(raw_payload=_expect_mapping(payload)) + + @dataclass(slots=True, frozen=True) class PromotionActionResult(SerializableModel): """Стабильный результат write-операции продвижения.""" @@ -267,6 +511,102 @@ class PromotionActionResult(SerializableModel): upstream_reference: str | None = None details: dict[str, object] = field(default_factory=dict) + @classmethod + def from_action_payload( + cls, + payload: object, + *, + action: str, + target: Mapping[str, object] | None, + request_payload: Mapping[str, object], + ) -> PromotionActionResult: + """Преобразует результат действия по продвижению.""" + + if isinstance(payload, PromotionActionPayload): + payload = payload.raw_payload + data = _expect_mapping(payload) + items_payload = _items_payload(data) + if not items_payload: + success_payload = _mapping(data, "success") + items_payload = _list(success_payload, "items", "result") + items = [ + PromotionActionItem( + item_id=_int(item, "itemId", "itemID"), + success=bool(item.get("success", True)), + status=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 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] = {} + if items: + details["items"] = [ + { + "item_id": item.item_id, + "success": item.success, + "status": item.status, + "message": item.message, + } + for item in items + ] + elif message := _str(data, "message", "status"): + details["message"] = message + return cls( + action=action, + target=dict(target) if target is not None else None, + status=resolved_status, + applied=applied, + request_payload=dict(request_payload), + warnings=messages if not applied else [], + upstream_reference=_extract_upstream_reference(data, items), + details=details, + ) + + +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 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 PromotionStatus.APPLIED if applied else PromotionStatus.FAILED + + +def _extract_upstream_reference( + payload: _Payload, + items: list[PromotionActionItem], +) -> str | None: + reference = _str(payload, "orderId", "requestId", "promotionId", "id") + if reference is not None: + return reference + for item in items: + if item.upstream_reference is not None: + return item.upstream_reference + return None + @dataclass(slots=True, frozen=True) class CreateBbipSuggestsRequest: @@ -313,6 +653,40 @@ class BbipSuggestsResult(SerializableModel): items: list[BbipSuggest] + @classmethod + def from_payload(cls, payload: object) -> BbipSuggestsResult: + """Преобразует варианты бюджета BBIP.""" + + data = _expect_mapping(payload) + return cls( + items=[ + BbipSuggest( + item_id=_int(item, "itemId", "itemID"), + duration=_map_bbip_duration(_mapping(item, "duration")), + budgets=[_map_bbip_budget(option) for option in _list(item, "budgets")], + ) + for item in _items_payload(data) + ], + ) + + +def _map_bbip_budget(payload: _Payload) -> BbipBudgetOption: + return BbipBudgetOption( + price=_int(payload, "price"), + old_price=_int(payload, "oldPrice"), + is_recommended=_bool(payload, "isRecommended"), + ) + + +def _map_bbip_duration(payload: _Payload) -> BbipDurationRange | None: + if not payload: + return None + return BbipDurationRange( + start=_int(payload, "from"), + stop=_int(payload, "to"), + recommended=_int(payload, "recommended"), + ) + @dataclass(slots=True, frozen=True) class TrxItem(SerializableModel): @@ -355,7 +729,19 @@ class CancelTrxPromotionRequest: def to_payload(self) -> dict[str, object]: """Сериализует запрос остановки TrxPromo.""" - return {"items": [{"itemID": item_id} for item_id in self.item_ids]} + return {"itemIDs": list(self.item_ids)} + + +@dataclass(slots=True, frozen=True) +class GetTrxCommissionsRequest: + """Запрос доступных комиссий TrxPromo.""" + + item_ids: list[int] + + def to_payload(self) -> dict[str, object]: + """Сериализует запрос доступных комиссий.""" + + return {"itemIDs": self.item_ids} @dataclass(slots=True, frozen=True) @@ -383,6 +769,37 @@ class TrxCommissionsResult(SerializableModel): items: list[TrxCommissionInfo] + @classmethod + def from_payload(cls, payload: object) -> TrxCommissionsResult: + """Преобразует комиссии TrxPromo.""" + + data = _expect_mapping(payload) + items_payload = _items_payload(data) + if not items_payload: + success_payload = _mapping(data, "success") + items_payload = _list(success_payload, "items", "result") + return cls( + items=[ + TrxCommissionInfo( + item_id=_int(item, "itemId", "itemID"), + commission=_int(item, "commission"), + is_active=_bool(item, "isActive", "active"), + valid_commission_range=_map_trx_range(_mapping(item, "validCommissionRange")), + ) + for item in items_payload + ], + ) + + +def _map_trx_range(payload: _Payload) -> TrxCommissionRange | None: + if not payload: + return None + return TrxCommissionRange( + value_min=_int(payload, "valueMin"), + value_max=_int(payload, "valueMax"), + step=_int(payload, "step"), + ) + @dataclass(slots=True, frozen=True) class CpaAuctionBidOption(SerializableModel): @@ -408,6 +825,37 @@ class CpaAuctionBidsResult(SerializableModel): items: list[CpaAuctionItemBid] + @classmethod + def from_payload(cls, payload: object) -> CpaAuctionBidsResult: + """Преобразует действующие и доступные ставки CPA-аукциона.""" + + data = _expect_mapping(payload) + return cls( + items=[ + CpaAuctionItemBid( + item_id=_int(item, "itemId", "itemID"), + price_penny=_int(item, "pricePenny"), + expiration_time=_datetime(item, "expirationTime"), + available_prices=[ + CpaAuctionBidOption( + price_penny=_int(option, "pricePenny"), + goodness=_int(option, "goodness"), + ) + for option in _list(item, "availablePrices") + ], + ) + for item in _items_payload(data) + ], + ) + + +@dataclass(slots=True, frozen=True) +class CpaAuctionBidInput(SerializableModel): + """Входные параметры одной ставки CPA-аукциона.""" + + item_id: int + price_penny: int + @dataclass(slots=True, frozen=True) class CreateItemBid: @@ -490,15 +938,100 @@ class TargetActionGetBidsResult(SerializableModel): auto: TargetActionAutoBids | None = None manual: TargetActionManualBids | None = None - -@dataclass(slots=True, frozen=True) -class TargetActionPromotion(SerializableModel): - """Текущая настройка цены целевого действия по объявлению.""" - - item_id: int - action_type_id: int - auto: TargetActionAutoPromotion | None = None - manual: TargetActionManualPromotion | None = None + @classmethod + def from_payload(cls, payload: object) -> TargetActionGetBidsResult: + """Преобразует documented shape GET /cpxpromo/1/getBids/{itemId}.""" + + data = _expect_mapping(payload) + action_type_id = _int(data, "actionTypeID") + 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`.", + payload=payload, + ) + return cls( + action_type_id=action_type_id, + selected_type=selected_type, + auto=( + _map_target_action_auto(cast(_Payload, data["auto"])) + if isinstance(data.get("auto"), Mapping) + else None + ), + manual=( + _map_target_action_manual(cast(_Payload, data["manual"])) + if isinstance(data.get("manual"), Mapping) + else None + ), + ) + + +def _map_target_action_bid(item: _Payload) -> TargetActionBid: + return TargetActionBid( + value_penny=_int(item, "valuePenny"), + min_forecast=_int(item, "minForecast"), + max_forecast=_int(item, "maxForecast"), + compare=_int(item, "compare"), + ) + + +def _map_target_action_budget(item: _Payload) -> TargetActionBudget: + return TargetActionBudget( + budget_penny=_int(item, "valuePenny"), + min_forecast=_int(item, "minForecast"), + max_forecast=_int(item, "maxForecast"), + compare=_int(item, "compare"), + ) + + +def _map_target_action_manual(payload: _Payload) -> TargetActionManualBids: + bids_payload = payload.get("bids") + if bids_payload is not None and not isinstance(bids_payload, list): + raise ResponseMappingError("Поле `manual.bids` должно быть массивом.", payload=payload) + return TargetActionManualBids( + bid_penny=_int(payload, "bidPenny"), + limit_penny=_int(payload, "limitPenny"), + rec_bid_penny=_int(payload, "recBidPenny"), + min_bid_penny=_int(payload, "minBidPenny"), + max_bid_penny=_int(payload, "maxBidPenny"), + min_limit_penny=_int(payload, "minLimitPenny"), + max_limit_penny=_int(payload, "maxLimitPenny"), + bids=[ + _map_target_action_bid(item) for item in bids_payload or [] if isinstance(item, Mapping) + ], + ) + + +def _map_budget_values(payload: _Payload, key: str) -> list[TargetActionBudget]: + budget = payload.get(key) + if budget is None: + return [] + if not isinstance(budget, Mapping): + raise ResponseMappingError(f"Поле `{key}` должно быть объектом.", payload=payload) + values = budget.get("budgets") + if values is not None and not isinstance(values, list): + raise ResponseMappingError(f"Поле `{key}.budgets` должно быть массивом.", payload=payload) + return [_map_target_action_budget(item) for item in values or [] if isinstance(item, Mapping)] + + +def _map_target_action_auto(payload: _Payload) -> TargetActionAutoBids: + return TargetActionAutoBids( + budget_penny=_int(payload, "budgetPenny"), + budget_type=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"), + weekly_budget=_map_budget_values(payload, "weeklyBudget"), + monthly_budget=_map_budget_values(payload, "monthlyBudget"), + ) @dataclass(slots=True, frozen=True) @@ -517,12 +1050,72 @@ class TargetActionManualPromotion(SerializableModel): limit_penny: int | None +@dataclass(slots=True, frozen=True) +class TargetActionPromotion(SerializableModel): + """Текущая настройка цены целевого действия по объявлению.""" + + item_id: int + action_type_id: int + auto: TargetActionAutoPromotion | None = None + manual: TargetActionManualPromotion | None = None + + @dataclass(slots=True, frozen=True) class TargetActionPromotionsByItemIdsResult(SerializableModel): """Ответ POST /cpxpromo/1/getPromotionsByItemIds.""" items: list[TargetActionPromotion] + @classmethod + def from_payload(cls, payload: object) -> TargetActionPromotionsByItemIdsResult: + """Преобразует documented shape POST /cpxpromo/1/getPromotionsByItemIds.""" + + data = _expect_mapping(payload) + items_payload = data.get("items") + if not isinstance(items_payload, list): + raise ResponseMappingError( + "Ответ getPromotionsByItemIds должен содержать массив `items`.", + payload=payload, + ) + items: list[TargetActionPromotion] = [] + for item in items_payload: + if not isinstance(item, Mapping): + continue + item_id = _int(item, "itemID") + action_type_id = _int(item, "actionTypeID") + if item_id is None or action_type_id is None: + raise ResponseMappingError( + "Элемент getPromotionsByItemIds должен содержать `itemID` и `actionTypeID`.", + payload=item, + ) + items.append( + TargetActionPromotion( + item_id=item_id, + action_type_id=action_type_id, + auto=( + TargetActionAutoPromotion( + budget_penny=_int(cast(_Payload, item["autoPromotion"]), "budgetPenny"), + 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 + ), + manual=( + TargetActionManualPromotion( + bid_penny=_int(cast(_Payload, item["manualPromotion"]), "bidPenny"), + limit_penny=_int(cast(_Payload, item["manualPromotion"]), "limitPenny"), + ) + if isinstance(item.get("manualPromotion"), Mapping) + else None + ), + ) + ) + return cls(items=items) + @dataclass(slots=True, frozen=True) class GetPromotionsByItemIdsRequest: @@ -626,6 +1219,46 @@ class AutostrategyBudget(SerializableModel): maximal: AutostrategyBudgetPoint | None price_ranges: list[AutostrategyPriceRange] + @classmethod + def from_payload(cls, payload: object) -> AutostrategyBudget: + """Преобразует расчет бюджета автокампании.""" + + data = _expect_mapping(payload) + source = _mapping(data, "budget") + return cls( + calc_id=_int(data, "calcId"), + recommended=_map_budget_point(_mapping(source, "recommended")), + minimal=_map_budget_point(_mapping(source, "minimal")), + maximal=_map_budget_point(_mapping(source, "maximal")), + price_ranges=[_map_price_range(item) for item in _list(source, "priceRanges")], + ) + + +def _map_budget_point(payload: _Payload) -> AutostrategyBudgetPoint | None: + if not payload: + return None + return AutostrategyBudgetPoint( + total=_int(payload, "total"), + real=_int(payload, "real"), + bonus=_int(payload, "bonus"), + calls_from=_int(payload, "callsFrom"), + calls_to=_int(payload, "callsTo"), + views_from=_int(payload, "viewsFrom"), + views_to=_int(payload, "viewsTo"), + ) + + +def _map_price_range(payload: _Payload) -> AutostrategyPriceRange: + return AutostrategyPriceRange( + price_from=_int(payload, "priceFrom"), + price_to=_int(payload, "priceTo"), + percent=_int(payload, "percent"), + calls_from=_int(payload, "callsFrom"), + calls_to=_int(payload, "callsTo"), + views_from=_int(payload, "viewsFrom"), + views_to=_int(payload, "viewsTo"), + ) + @dataclass(slots=True, frozen=True) class CreateAutostrategyBudgetRequest: @@ -649,13 +1282,6 @@ def to_payload(self) -> dict[str, object]: return payload -@dataclass(slots=True, frozen=True) -class CampaignActionResult(SerializableModel): - """Результат операции с автокампанией.""" - - campaign: CampaignInfo | None - - @dataclass(slots=True, frozen=True) class CampaignInfo(SerializableModel): """Информация об автокампании.""" @@ -676,6 +1302,45 @@ class CampaignInfo(SerializableModel): version: int | None +@dataclass(slots=True, frozen=True) +class CampaignActionResult(SerializableModel): + """Результат операции с автокампанией.""" + + campaign: CampaignInfo | None + + @classmethod + def from_payload(cls, payload: object) -> CampaignActionResult: + """Преобразует результат операции с автокампанией.""" + + data = _expect_mapping(payload) + return cls(campaign=_map_campaign(_mapping(data, "campaign"))) + + +def _map_campaign(payload: _Payload) -> CampaignInfo | None: + if not payload: + return None + return CampaignInfo( + campaign_id=_int(payload, "campaignId"), + campaign_type=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"), + description=_str(payload, "description"), + finish_time=_datetime(payload, "finishTime"), + items_count=_int(payload, "itemsCount"), + start_time=_datetime(payload, "startTime"), + status_id=_int(payload, "statusId"), + title=_str(payload, "title"), + update_time=_datetime(payload, "updateTime"), + user_id=_int(payload, "userId"), + version=_int(payload, "version"), + ) + + @dataclass(slots=True, frozen=True) class CampaignForecastRange(SerializableModel): """Диапазон прогноза кампании.""" @@ -708,6 +1373,42 @@ class CampaignDetailsResult(SerializableModel): forecast: CampaignForecast | None items: list[CampaignItem] + @classmethod + def from_payload(cls, payload: object) -> CampaignDetailsResult: + """Преобразует полную информацию об автокампании.""" + + data = _expect_mapping(payload) + return cls( + campaign=_map_campaign(_mapping(data, "campaign")), + forecast=_map_campaign_forecast(_mapping(data, "forecast")), + items=[_map_campaign_item(item) for item in _list(data, "items")], + ) + + +def _map_campaign_forecast(payload: _Payload) -> CampaignForecast | None: + if not payload: + return None + return CampaignForecast( + calls=_map_campaign_forecast_range(_mapping(payload, "calls")), + views=_map_campaign_forecast_range(_mapping(payload, "views")), + ) + + +def _map_campaign_forecast_range(payload: _Payload) -> CampaignForecastRange | None: + if not payload: + return None + return CampaignForecastRange( + from_value=_int(payload, "from"), + to_value=_int(payload, "to"), + ) + + +def _map_campaign_item(payload: _Payload) -> CampaignItem: + return CampaignItem( + item_id=_int(payload, "itemId"), + is_active=_bool(payload, "isActive"), + ) + @dataclass(slots=True, frozen=True) class CampaignsResult(SerializableModel): @@ -716,6 +1417,38 @@ class CampaignsResult(SerializableModel): items: list[CampaignInfo] total_count: int | None = None + @classmethod + def from_payload(cls, payload: object) -> CampaignsResult: + """Преобразует список автокампаний.""" + + data = _expect_mapping(payload) + items: list[CampaignInfo] = [] + for raw in _list(data, "campaigns"): + campaign = _map_campaign(raw) + if campaign is None: + raise ResponseMappingError("Не удалось смэппить кампанию.", payload=raw) + items.append(campaign) + return cls(items=items, total_count=_int(data, "totalCount")) + + +@dataclass(slots=True, frozen=True) +class AutostrategyStatItem(SerializableModel): + """Статистика кампании за день.""" + + date: datetime | None + calls: int | None + views: int | None + calls_forecast: CampaignForecastRange | None = None + views_forecast: CampaignForecastRange | None = None + + +@dataclass(slots=True, frozen=True) +class AutostrategyStatTotals(SerializableModel): + """Суммарная статистика кампании.""" + + calls: int | None + views: int | None + @dataclass(slots=True, frozen=True) class AutostrategyStat(SerializableModel): @@ -724,6 +1457,35 @@ class AutostrategyStat(SerializableModel): items: list[AutostrategyStatItem] totals: AutostrategyStatTotals | None + @classmethod + def from_payload(cls, payload: object) -> AutostrategyStat: + """Преобразует статистику автокампании.""" + + data = _expect_mapping(payload) + return cls( + items=[_map_autostrategy_stat_item(item) for item in _list(data, "stat")], + totals=_map_autostrategy_stat_totals(_mapping(data, "totals")), + ) + + +def _map_autostrategy_stat_item(payload: _Payload) -> AutostrategyStatItem: + return AutostrategyStatItem( + date=_datetime(payload, "date"), + calls=_int(payload, "calls"), + views=_int(payload, "views"), + calls_forecast=_map_campaign_forecast_range(_mapping(payload, "callsForecast")), + views_forecast=_map_campaign_forecast_range(_mapping(payload, "viewsForecast")), + ) + + +def _map_autostrategy_stat_totals(payload: _Payload) -> AutostrategyStatTotals | None: + if not payload: + return None + return AutostrategyStatTotals( + calls=_int(payload, "calls"), + views=_int(payload, "views"), + ) + @dataclass(slots=True, frozen=True) class CreateAutostrategyCampaignRequest: @@ -910,22 +1672,3 @@ def to_payload(self) -> dict[str, object]: """Сериализует запрос статистики кампании.""" return {"campaignId": self.campaign_id} - - -@dataclass(slots=True, frozen=True) -class AutostrategyStatItem(SerializableModel): - """Статистика кампании за день.""" - - date: datetime | None - calls: int | None - views: int | None - calls_forecast: CampaignForecastRange | None = None - views_forecast: CampaignForecastRange | None = None - - -@dataclass(slots=True, frozen=True) -class AutostrategyStatTotals(SerializableModel): - """Суммарная статистика кампании.""" - - calls: int | None - views: int | None diff --git a/avito/promotion/operations.py b/avito/promotion/operations.py new file mode 100644 index 0000000..cb99f28 --- /dev/null +++ b/avito/promotion/operations.py @@ -0,0 +1,233 @@ +"""Operation specs for promotion domain.""" + +from __future__ import annotations + +from avito.core import OperationSpec +from avito.promotion.models import ( + AutostrategyBudget, + AutostrategyStat, + BbipForecastsResult, + BbipSuggestsResult, + CampaignActionResult, + CampaignDetailsResult, + CampaignsResult, + CancelTrxPromotionRequest, + CpaAuctionBidsResult, + CreateAutostrategyBudgetRequest, + CreateAutostrategyCampaignRequest, + CreateBbipForecastsRequest, + CreateBbipOrderRequest, + CreateBbipSuggestsRequest, + CreateItemBidsRequest, + CreateTrxPromotionApplyRequest, + DeletePromotionRequest, + GetAutostrategyCampaignInfoRequest, + GetAutostrategyStatRequest, + GetPromotionOrderStatusRequest, + GetPromotionsByItemIdsRequest, + GetTrxCommissionsRequest, + ListAutostrategyCampaignsRequest, + ListPromotionOrdersRequest, + ListPromotionServicesRequest, + PromotionActionPayload, + PromotionOrdersResult, + PromotionOrderStatusResult, + PromotionServiceDictionary, + PromotionServicesResult, + StopAutostrategyCampaignRequest, + TargetActionGetBidsResult, + TargetActionPromotionsByItemIdsResult, + TrxCommissionsResult, + UpdateAutoBidRequest, + UpdateAutostrategyCampaignRequest, + UpdateManualBidRequest, +) + +GET_SERVICE_DICTIONARY = OperationSpec( + name="promotion.get_service_dictionary", + method="POST", + path="/promotion/v1/items/services/dict", + response_model=PromotionServiceDictionary, + retry_mode="enabled", +) +LIST_SERVICES = OperationSpec( + name="promotion.list_services", + method="POST", + path="/promotion/v1/items/services/get", + request_model=ListPromotionServicesRequest, + response_model=PromotionServicesResult, + retry_mode="enabled", +) +LIST_ORDERS = OperationSpec( + name="promotion.list_orders", + method="POST", + path="/promotion/v1/items/services/orders/get", + request_model=ListPromotionOrdersRequest, + response_model=PromotionOrdersResult, + retry_mode="enabled", +) +GET_ORDER_STATUS = OperationSpec( + name="promotion.get_order_status", + method="POST", + path="/promotion/v1/items/services/orders/status", + request_model=GetPromotionOrderStatusRequest, + response_model=PromotionOrderStatusResult, + retry_mode="enabled", +) +GET_BBIP_FORECASTS = OperationSpec( + name="promotion.bbip.get_forecasts", + method="POST", + path="/promotion/v1/items/services/bbip/forecasts/get", + request_model=CreateBbipForecastsRequest, + response_model=BbipForecastsResult, + retry_mode="enabled", +) +CREATE_BBIP_ORDER: OperationSpec[object] = OperationSpec( + name="promotion.bbip.create_order", + method="PUT", + path="/promotion/v1/items/services/bbip/orders/create", + request_model=CreateBbipOrderRequest, + response_model=PromotionActionPayload, + retry_mode="enabled", +) +GET_BBIP_SUGGESTS = OperationSpec( + name="promotion.bbip.get_suggests", + method="POST", + path="/promotion/v1/items/services/bbip/suggests/get", + request_model=CreateBbipSuggestsRequest, + response_model=BbipSuggestsResult, + retry_mode="enabled", +) +APPLY_TRX: OperationSpec[object] = OperationSpec( + name="promotion.trx.apply", + method="POST", + path="/trx-promo/1/apply", + request_model=CreateTrxPromotionApplyRequest, + response_model=PromotionActionPayload, + retry_mode="enabled", +) +CANCEL_TRX: OperationSpec[object] = OperationSpec( + name="promotion.trx.cancel", + method="POST", + path="/trx-promo/1/cancel", + request_model=CancelTrxPromotionRequest, + response_model=PromotionActionPayload, + retry_mode="enabled", +) +GET_TRX_COMMISSIONS = OperationSpec( + name="promotion.trx.get_commissions", + method="GET", + path="/trx-promo/1/commissions", + request_model=GetTrxCommissionsRequest, + response_model=TrxCommissionsResult, +) +GET_CPA_AUCTION_BIDS = OperationSpec( + name="promotion.cpa_auction.get_user_bids", + method="GET", + path="/auction/1/bids", + response_model=CpaAuctionBidsResult, +) +CREATE_CPA_AUCTION_BIDS: OperationSpec[object] = OperationSpec( + name="promotion.cpa_auction.create_item_bids", + method="POST", + path="/auction/1/bids", + request_model=CreateItemBidsRequest, + retry_mode="enabled", +) +GET_TARGET_ACTION_BIDS = OperationSpec( + name="promotion.target_action.get_bids", + method="GET", + path="/cpxpromo/1/getBids/{itemId}", + response_model=TargetActionGetBidsResult, +) +GET_TARGET_ACTION_PROMOTIONS = OperationSpec( + name="promotion.target_action.get_promotions_by_item_ids", + method="POST", + path="/cpxpromo/1/getPromotionsByItemIds", + request_model=GetPromotionsByItemIdsRequest, + response_model=TargetActionPromotionsByItemIdsResult, + retry_mode="enabled", +) +DELETE_TARGET_ACTION_PROMOTION: OperationSpec[object] = OperationSpec( + name="promotion.target_action.delete_promotion", + method="POST", + path="/cpxpromo/1/remove", + request_model=DeletePromotionRequest, + response_model=PromotionActionPayload, + retry_mode="enabled", +) +UPDATE_TARGET_ACTION_AUTO: OperationSpec[object] = OperationSpec( + name="promotion.target_action.update_auto_bid", + method="POST", + path="/cpxpromo/1/setAuto", + request_model=UpdateAutoBidRequest, + retry_mode="enabled", +) +UPDATE_TARGET_ACTION_MANUAL: OperationSpec[object] = OperationSpec( + name="promotion.target_action.update_manual_bid", + method="POST", + path="/cpxpromo/1/setManual", + request_model=UpdateManualBidRequest, + retry_mode="enabled", +) +CREATE_AUTOSTRATEGY_BUDGET = OperationSpec( + name="promotion.autostrategy.create_budget", + method="POST", + path="/autostrategy/v1/budget", + request_model=CreateAutostrategyBudgetRequest, + response_model=AutostrategyBudget, + retry_mode="enabled", +) +CREATE_AUTOSTRATEGY_CAMPAIGN = OperationSpec( + name="promotion.autostrategy.create_campaign", + method="POST", + path="/autostrategy/v1/campaign/create", + request_model=CreateAutostrategyCampaignRequest, + response_model=CampaignActionResult, + retry_mode="enabled", +) +UPDATE_AUTOSTRATEGY_CAMPAIGN = OperationSpec( + name="promotion.autostrategy.edit_campaign", + method="POST", + path="/autostrategy/v1/campaign/edit", + request_model=UpdateAutostrategyCampaignRequest, + response_model=CampaignActionResult, + retry_mode="enabled", +) +GET_AUTOSTRATEGY_CAMPAIGN = OperationSpec( + name="promotion.autostrategy.get_campaign_info", + method="POST", + path="/autostrategy/v1/campaign/info", + request_model=GetAutostrategyCampaignInfoRequest, + response_model=CampaignDetailsResult, + retry_mode="enabled", +) +DELETE_AUTOSTRATEGY_CAMPAIGN = OperationSpec( + name="promotion.autostrategy.stop_campaign", + method="POST", + path="/autostrategy/v1/campaign/stop", + request_model=StopAutostrategyCampaignRequest, + response_model=CampaignActionResult, + retry_mode="enabled", +) +LIST_AUTOSTRATEGY_CAMPAIGNS = OperationSpec( + name="promotion.autostrategy.list_campaigns", + method="POST", + path="/autostrategy/v1/campaigns", + request_model=ListAutostrategyCampaignsRequest, + response_model=CampaignsResult, + retry_mode="enabled", +) +GET_AUTOSTRATEGY_STAT = OperationSpec( + name="promotion.autostrategy.get_stat", + method="POST", + path="/autostrategy/v1/stat", + request_model=GetAutostrategyStatRequest, + response_model=AutostrategyStat, + retry_mode="enabled", +) + +TRX_HEADERS = { + "x-authenticated-userid": "7", + "x-oauth-flow": "client_credentials", +} diff --git a/avito/ratings/__init__.py b/avito/ratings/__init__.py index 73e31d3..9bf5554 100644 --- a/avito/ratings/__init__.py +++ b/avito/ratings/__init__.py @@ -1,8 +1,14 @@ """Пакет ratings.""" from avito.ratings.domain import RatingProfile, Review, ReviewAnswer -from avito.ratings.enums import ReviewAnswerStatus, ReviewStage -from avito.ratings.models import RatingProfileInfo, ReviewAnswerInfo, ReviewInfo, ReviewsResult +from avito.ratings.models import ( + RatingProfileInfo, + ReviewAnswerInfo, + ReviewAnswerStatus, + ReviewInfo, + ReviewsResult, + ReviewStage, +) __all__ = ( "RatingProfile", diff --git a/avito/ratings/client.py b/avito/ratings/client.py deleted file mode 100644 index 18bb870..0000000 --- a/avito/ratings/client.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Внутренние section clients для пакета ratings.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from avito.core import RequestContext, Transport -from avito.ratings.mappers import map_rating_profile, map_review_answer, map_reviews -from avito.ratings.models import ( - CreateReviewAnswerRequest, - RatingProfileInfo, - ReviewAnswerInfo, - ReviewsQuery, - ReviewsResult, -) - - -@dataclass(slots=True, frozen=True) -class RatingsClient: - """Выполняет HTTP-операции рейтингов и отзывов.""" - - transport: Transport - - 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=idempotency_key is not None), - mapper=map_review_answer, - json_body=CreateReviewAnswerRequest(review_id=review_id, text=text).to_payload(), - idempotency_key=idempotency_key, - ) - - 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=idempotency_key is not None), - mapper=map_review_answer, - idempotency_key=idempotency_key, - ) - - def get_ratings_info(self) -> RatingProfileInfo: - return self.transport.request_public_model( - "GET", - "/ratings/v1/info", - context=RequestContext("ratings.info.get"), - mapper=map_rating_profile, - ) - - def list_reviews(self, *, query: ReviewsQuery | None = None) -> ReviewsResult: - resolved_query = ReviewsQuery( - offset=query.offset if query is not None and query.offset is not None else 0, - page=query.page if query is not None and query.page is not None else 1, - limit=query.limit if query is not None and query.limit is not None else 50, - ) - return self.transport.request_public_model( - "GET", - "/ratings/v1/reviews", - context=RequestContext("ratings.reviews.list"), - mapper=map_reviews, - params=resolved_query.to_params(), - ) diff --git a/avito/ratings/domain.py b/avito/ratings/domain.py index f0f8ccc..8830141 100644 --- a/avito/ratings/domain.py +++ b/avito/ratings/domain.py @@ -4,16 +4,22 @@ from dataclasses import dataclass -from avito.core import ValidationError +from avito.core import ApiTimeouts, RetryOverride, ValidationError from avito.core.domain import DomainObject from avito.core.swagger import swagger_operation -from avito.ratings.client import RatingsClient from avito.ratings.models import ( + CreateReviewAnswerRequest, RatingProfileInfo, ReviewAnswerInfo, ReviewsQuery, ReviewsResult, ) +from avito.ratings.operations import ( + CREATE_REVIEW_ANSWER, + DELETE_REVIEW_ANSWER, + GET_RATINGS_INFO, + LIST_REVIEWS, +) @dataclass(slots=True, frozen=True) @@ -31,15 +37,41 @@ class Review(DomainObject): spec="Рейтингииотзывы.json", operation_id="getReviewsV1", ) - def list(self, *, query: ReviewsQuery | None = None) -> ReviewsResult: - """Выполняет публичную операцию `Review.list` и возвращает типизированную SDK-модель. - - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + def list( + self, + *, + offset: int | None = None, + page: int | None = None, + limit: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ReviewsResult: + """Возвращает список отзывов. + + Аргументы: + offset: задает смещение первой записи в выборке. + page: задает номер страницы для постраничной выборки. + limit: ограничивает размер возвращаемой выборки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ReviewsResult` с типизированными данными ответа API. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return RatingsClient(self.transport).list_reviews(query=query) + resolved_query = ReviewsQuery( + offset=offset if offset is not None else 0, + page=page if page is not None else 1, + limit=limit if limit is not None else 50, + ) + return self._execute(LIST_REVIEWS, query=resolved_query, timeout=timeout, retry=retry) @dataclass(slots=True, frozen=True) @@ -66,20 +98,35 @@ def create( review_id: int, text: str, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> ReviewAnswerInfo: - """Выполняет публичную операцию `ReviewAnswer.create` и возвращает типизированную SDK-модель. + """Создает ответ на отзыв. + + Аргументы: + review_id: идентифицирует отзыв. + text: передает текст ответа или сообщения. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `ReviewAnswerInfo` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return RatingsClient(self.transport).create_review_answer( - review_id=review_id, - text=text, + return self._execute( + CREATE_REVIEW_ANSWER, + request=CreateReviewAnswerRequest(review_id=review_id, text=text), idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -93,19 +140,34 @@ def delete( *, answer_id: int | str | None = None, idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> ReviewAnswerInfo: - """Выполняет публичную операцию `ReviewAnswer.delete` и возвращает типизированную SDK-модель. + """Удаляет ответ на отзыв. + + Аргументы: + answer_id: идентифицирует ответ на отзыв. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + Возвращает: + `ReviewAnswerInfo` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return RatingsClient(self.transport).delete_review_answer( - answer_id=answer_id or self._require_answer_id(), + return self._execute( + DELETE_REVIEW_ANSWER, + path_params={"answer_id": answer_id or self._require_answer_id()}, idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) def _require_answer_id(self) -> str: @@ -129,15 +191,26 @@ class RatingProfile(DomainObject): spec="Рейтингииотзывы.json", operation_id="getRatingsInfoV1", ) - def get(self) -> RatingProfileInfo: - """Выполняет публичную операцию `RatingProfile.get` и возвращает типизированную SDK-модель. + def get( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> RatingProfileInfo: + """Возвращает рейтинга профиля. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RatingProfileInfo` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return RatingsClient(self.transport).get_ratings_info() + return self._execute(GET_RATINGS_INFO, timeout=timeout, retry=retry) __all__ = ("RatingProfile", "Review", "ReviewAnswer") diff --git a/avito/ratings/enums.py b/avito/ratings/enums.py deleted file mode 100644 index 0c5302e..0000000 --- a/avito/ratings/enums.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Enum-значения раздела ratings.""" - -from __future__ import annotations - -from enum import Enum - - -class ReviewStage(str, Enum): - """Этап обработки отзыва.""" - - UNKNOWN = "__unknown__" - DONE = "done" - FELL_THROUGH = "fell_through" - NOT_AGREE = "not_agree" - NOT_COMMUNICATE = "not_communicate" - - -class ReviewAnswerStatus(str, Enum): - """Статус ответа на отзыв.""" - - UNKNOWN = "__unknown__" - MODERATION = "moderation" - PUBLISHED = "published" - REJECTED = "rejected" - - -__all__ = ("ReviewAnswerStatus", "ReviewStage") diff --git a/avito/ratings/mappers.py b/avito/ratings/mappers.py deleted file mode 100644 index ed5255c..0000000 --- a/avito/ratings/mappers.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Мапперы JSON -> dataclass для пакета ratings.""" - -from __future__ import annotations - -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 ReviewAnswerStatus, ReviewStage -from avito.ratings.models import RatingProfileInfo, ReviewAnswerInfo, ReviewInfo, ReviewsResult - -Payload = Mapping[str, object] - - -def _expect_mapping(payload: object) -> Payload: - if not isinstance(payload, Mapping): - raise ResponseMappingError("Ожидался JSON-объект.", payload=payload) - return cast(Payload, payload) - - -def _mapping(payload: Payload, *keys: str) -> Payload: - for key in keys: - value = payload.get(key) - if isinstance(value, Mapping): - return cast(Payload, value) - return {} - - -def _list(payload: Payload, *keys: str) -> list[Payload]: - for key in keys: - value = payload.get(key) - if isinstance(value, list): - return [item for item in value if isinstance(item, Mapping)] - return [] - - -def _str(payload: Payload, *keys: str) -> str | None: - for key in keys: - value = payload.get(key) - if isinstance(value, str): - return value - if isinstance(value, int) and not isinstance(value, bool): - return str(value) - return None - - -def _int(payload: Payload, *keys: str) -> int | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - continue - if isinstance(value, int): - return value - return None - - -def _bool(payload: Payload, *keys: str) -> bool | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - return value - return None - - -def _float(payload: Payload, *keys: str) -> float | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - continue - if isinstance(value, (int, float)): - return float(value) - return None - - -def map_review_answer(payload: object) -> ReviewAnswerInfo: - """Преобразует ответ на создание или удаление ответа.""" - - data = _expect_mapping(payload) - return ReviewAnswerInfo( - answer_id=_str(data, "id"), - created_at=_int(data, "createdAt"), - success=_bool(data, "success"), - status=map_enum_or_unknown( - _str(data, "status"), - ReviewAnswerStatus, - enum_name="ratings.review_answer_status", - ), - ) - - -def map_rating_profile(payload: object) -> RatingProfileInfo: - """Преобразует профиль рейтинга.""" - - data = _expect_mapping(payload) - rating = _mapping(data, "rating") - return RatingProfileInfo( - is_enabled=bool(data.get("isEnabled", False)), - score=_float(rating, "score"), - reviews_count=_int(rating, "reviewsCount"), - reviews_with_score_count=_int(rating, "reviewsWithScoreCount"), - ) - - -def map_reviews(payload: object) -> ReviewsResult: - """Преобразует список отзывов.""" - - data = _expect_mapping(payload) - return ReviewsResult( - items=[ - ReviewInfo( - review_id=_str(item, "id"), - score=_int(item, "score"), - 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"), - used_in_score=_bool(item, "usedInScore"), - ) - for item in _list(data, "reviews", "items") - ], - total=_int(data, "total"), - ) diff --git a/avito/ratings/models.py b/avito/ratings/models.py index a82f456..f8bd728 100644 --- a/avito/ratings/models.py +++ b/avito/ratings/models.py @@ -2,24 +2,44 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass +from enum import Enum -from avito.core.serialization import SerializableModel -from avito.ratings.enums import ReviewAnswerStatus, ReviewStage +from avito.core import ApiModel, JsonReader, RequestModel + + +class ReviewStage(str, Enum): + """Этап обработки отзыва.""" + + UNKNOWN = "__unknown__" + DONE = "done" + FELL_THROUGH = "fell_through" + NOT_AGREE = "not_agree" + NOT_COMMUNICATE = "not_communicate" + + +class ReviewAnswerStatus(str, Enum): + """Статус ответа на отзыв.""" + + UNKNOWN = "__unknown__" + MODERATION = "moderation" + PUBLISHED = "published" + REJECTED = "rejected" @dataclass(slots=True, frozen=True) -class ReviewsQuery: +class ReviewsQuery(RequestModel): """Query-параметры списка отзывов.""" offset: int | None = None page: int | None = None limit: int | None = None - def to_params(self) -> dict[str, int]: + def to_params(self) -> dict[str, object]: """Сериализует query-параметры списка отзывов.""" - params: dict[str, int] = {} + params: dict[str, object] = {} if self.offset is not None: params["offset"] = self.offset if self.page is not None: @@ -33,7 +53,7 @@ def to_params(self) -> dict[str, int]: @dataclass(slots=True, frozen=True) -class CreateReviewAnswerRequest: +class CreateReviewAnswerRequest(RequestModel): """Запрос создания ответа на отзыв.""" review_id: int @@ -42,11 +62,11 @@ class CreateReviewAnswerRequest: def to_payload(self) -> dict[str, object]: """Сериализует запрос создания ответа.""" - return {"reviewId": self.review_id, "text": self.text} + return {"reviewId": self.review_id, "message": self.text} @dataclass(slots=True, frozen=True) -class ReviewInfo(SerializableModel): +class ReviewInfo(ApiModel): """Информация об отзыве пользователя.""" review_id: str | None @@ -57,17 +77,47 @@ class ReviewInfo(SerializableModel): can_answer: bool | None used_in_score: bool | None + @classmethod + def from_payload(cls, payload: object) -> ReviewInfo: + """Преобразует JSON-объект отзыва в SDK-модель.""" + + data = JsonReader.expect_mapping(payload) + reader = JsonReader(payload) + return cls( + review_id=_optional_str_or_int(data, "id"), + score=reader.optional_int("score"), + stage=reader.enum(ReviewStage, "stage", unknown=ReviewStage.UNKNOWN), + text=reader.optional_str("text"), + created_at=reader.optional_int("createdAt"), + can_answer=reader.optional_bool("canAnswer"), + used_in_score=reader.optional_bool("usedInScore"), + ) + @dataclass(slots=True, frozen=True) -class ReviewsResult(SerializableModel): +class ReviewsResult(ApiModel): """Список отзывов пользователя.""" items: list[ReviewInfo] total: int | None = None + @classmethod + def from_payload(cls, payload: object) -> ReviewsResult: + """Преобразует ответ API со списком отзывов в SDK-модель.""" + + reader = JsonReader(payload) + return cls( + items=[ + ReviewInfo.from_payload(item) + for item in reader.list("reviews", "items") + if isinstance(item, Mapping) + ], + total=reader.optional_int("total"), + ) + @dataclass(slots=True, frozen=True) -class ReviewAnswerInfo(SerializableModel): +class ReviewAnswerInfo(ApiModel): """Информация об ответе на отзыв.""" answer_id: str | None = None @@ -75,12 +125,64 @@ class ReviewAnswerInfo(SerializableModel): success: bool | None = None status: ReviewAnswerStatus | None = None + @classmethod + def from_payload(cls, payload: object) -> ReviewAnswerInfo: + """Преобразует ответ API на создание или удаление ответа в SDK-модель.""" + + data = JsonReader.expect_mapping(payload) + reader = JsonReader(payload) + return cls( + answer_id=_optional_str_or_int(data, "id"), + created_at=reader.optional_int("createdAt"), + success=reader.optional_bool("success"), + status=reader.enum( + ReviewAnswerStatus, + "status", + unknown=ReviewAnswerStatus.UNKNOWN, + ), + ) + @dataclass(slots=True, frozen=True) -class RatingProfileInfo(SerializableModel): +class RatingProfileInfo(ApiModel): """Информация о рейтинговом профиле.""" is_enabled: bool score: float | None = None reviews_count: int | None = None reviews_with_score_count: int | None = None + + @classmethod + def from_payload(cls, payload: object) -> RatingProfileInfo: + """Преобразует ответ API с рейтингом профиля в SDK-модель.""" + + reader = JsonReader(payload) + rating = reader.mapping("rating") or {} + rating_reader = JsonReader(rating) + return cls( + is_enabled=reader.optional_bool("isEnabled") or False, + score=rating_reader.optional_float("score"), + reviews_count=rating_reader.optional_int("reviewsCount"), + reviews_with_score_count=rating_reader.optional_int("reviewsWithScoreCount"), + ) + + +def _optional_str_or_int(payload: Mapping[str, object], key: str) -> str | None: + value = payload.get(key) + if isinstance(value, str): + return value + if isinstance(value, int) and not isinstance(value, bool): + return str(value) + return None + + +__all__ = ( + "CreateReviewAnswerRequest", + "RatingProfileInfo", + "ReviewAnswerInfo", + "ReviewAnswerStatus", + "ReviewInfo", + "ReviewStage", + "ReviewsQuery", + "ReviewsResult", +) diff --git a/avito/ratings/operations.py b/avito/ratings/operations.py new file mode 100644 index 0000000..1a4544e --- /dev/null +++ b/avito/ratings/operations.py @@ -0,0 +1,48 @@ +"""Operation specs for ratings domain.""" + +from __future__ import annotations + +from avito.core import OperationSpec +from avito.ratings.models import ( + CreateReviewAnswerRequest, + RatingProfileInfo, + ReviewAnswerInfo, + ReviewsQuery, + ReviewsResult, +) + +CREATE_REVIEW_ANSWER = OperationSpec( + name="ratings.answers.create", + method="POST", + path="/ratings/v1/answers", + request_model=CreateReviewAnswerRequest, + response_model=ReviewAnswerInfo, + retry_mode="enabled", +) +DELETE_REVIEW_ANSWER = OperationSpec( + name="ratings.answers.delete", + method="DELETE", + path="/ratings/v1/answers/{answer_id}", + response_model=ReviewAnswerInfo, + retry_mode="enabled", +) +GET_RATINGS_INFO = OperationSpec( + name="ratings.info.get", + method="GET", + path="/ratings/v1/info", + response_model=RatingProfileInfo, +) +LIST_REVIEWS = OperationSpec( + name="ratings.reviews.list", + method="GET", + path="/ratings/v1/reviews", + query_model=ReviewsQuery, + response_model=ReviewsResult, +) + +__all__ = ( + "CREATE_REVIEW_ANSWER", + "DELETE_REVIEW_ANSWER", + "GET_RATINGS_INFO", + "LIST_REVIEWS", +) diff --git a/avito/realty/__init__.py b/avito/realty/__init__.py index 2ffdf40..36409da 100644 --- a/avito/realty/__init__.py +++ b/avito/realty/__init__.py @@ -6,7 +6,6 @@ RealtyListing, RealtyPricing, ) -from avito.realty.enums import RealtyBookingStatus, RealtyOperationStatus, RealtyStatus from avito.realty.models import ( RealtyActionResult, RealtyAnalyticsInfo, @@ -14,12 +13,15 @@ RealtyBookingInfo, RealtyBookingsQuery, RealtyBookingsResult, + RealtyBookingStatus, RealtyBookingsUpdateRequest, RealtyInterval, RealtyIntervalsRequest, RealtyMarketPriceInfo, + RealtyOperationStatus, RealtyPricePeriod, RealtyPricesUpdateRequest, + RealtyStatus, ) __all__ = ( diff --git a/avito/realty/client.py b/avito/realty/client.py deleted file mode 100644 index 4d81779..0000000 --- a/avito/realty/client.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Внутренние section clients для пакета realty.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from avito.core import RequestContext, Transport -from avito.realty.mappers import map_action, map_analytics_report, map_bookings, map_market_price -from avito.realty.models import ( - RealtyActionResult, - RealtyAnalyticsInfo, - RealtyBaseParamsUpdateRequest, - RealtyBookingsQuery, - RealtyBookingsResult, - RealtyBookingsUpdateRequest, - RealtyInterval, - RealtyIntervalsRequest, - RealtyMarketPriceInfo, - RealtyPricePeriod, - RealtyPricesUpdateRequest, -) - - -@dataclass(slots=True, frozen=True) -class ShortTermRentClient: - """Выполняет HTTP-операции краткосрочной аренды.""" - - transport: Transport - - def update_bookings_info( - self, - *, - user_id: int | str, - item_id: int | str, - blocked_dates: list[str], - idempotency_key: str | None = None, - ) -> RealtyActionResult: - return self.transport.request_public_model( - "POST", - f"/core/v1/accounts/{user_id}/items/{item_id}/bookings", - 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, - ) - - def list_realty_bookings( - self, *, user_id: int | str, item_id: int | str, query: RealtyBookingsQuery - ) -> RealtyBookingsResult: - 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(), - ) - - def update_realty_prices( - self, - *, - user_id: int | str, - item_id: int | str, - periods: list[RealtyPricePeriod], - idempotency_key: str | None = None, - ) -> RealtyActionResult: - return self.transport.request_public_model( - "POST", - f"/realty/v1/accounts/{user_id}/items/{item_id}/prices", - 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, - ) - - 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=idempotency_key is not None), - mapper=map_action, - json_body=RealtyIntervalsRequest(item_id=item_id, intervals=intervals).to_payload(), - idempotency_key=idempotency_key, - ) - - def update_base_params( - self, - *, - item_id: int | str, - min_stay_days: int, - idempotency_key: str | None = None, - ) -> RealtyActionResult: - return self.transport.request_public_model( - "POST", - f"/realty/v1/items/{item_id}/base", - 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, - ) - - -@dataclass(slots=True, frozen=True) -class RealtyAnalyticsClient: - """Выполняет HTTP-операции аналитики недвижимости.""" - - transport: Transport - - def get_market_price_correspondence( - self, *, item_id: int | str, price: int | str - ) -> RealtyMarketPriceInfo: - return self.transport.request_public_model( - "GET", - f"/realty/v1/marketPriceCorrespondence/{item_id}/{price}", - context=RequestContext("realty.analytics.market_price"), - mapper=map_market_price, - ) - - 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=idempotency_key is not None), - mapper=map_analytics_report, - idempotency_key=idempotency_key, - ) diff --git a/avito/realty/domain.py b/avito/realty/domain.py index 7d080fc..a1b43fd 100644 --- a/avito/realty/domain.py +++ b/avito/realty/domain.py @@ -4,18 +4,31 @@ from dataclasses import dataclass -from avito.core import ValidationError +from avito.core import ApiTimeouts, RetryOverride, ValidationError from avito.core.domain import DomainObject from avito.core.swagger import swagger_operation -from avito.realty.client import RealtyAnalyticsClient, ShortTermRentClient +from avito.core.validation import DateInput, serialize_iso_date from avito.realty.models import ( RealtyActionResult, RealtyAnalyticsInfo, + RealtyBaseParamsUpdateRequest, RealtyBookingsQuery, RealtyBookingsResult, + RealtyBookingsUpdateRequest, RealtyInterval, + RealtyIntervalsRequest, RealtyMarketPriceInfo, RealtyPricePeriod, + RealtyPricesUpdateRequest, +) +from avito.realty.operations import ( + GET_INTERVALS, + GET_MARKET_PRICE_CORRESPONDENCE, + GET_REPORT_FOR_CLASSIFIED, + LIST_REALTY_BOOKINGS, + UPDATE_BASE_PARAMS, + UPDATE_BOOKINGS_INFO, + UPDATE_REALTY_PRICES, ) @@ -42,17 +55,39 @@ def get_intervals( *, intervals: list[RealtyInterval], item_id: int | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> RealtyActionResult: - """Выполняет публичную операцию `RealtyListing.get_intervals` и возвращает типизированную SDK-модель. + """Возвращает intervals для посутчной аренды. + + Аргументы: + intervals: передает интервалы доступности объявления. + item_id: идентифицирует объявление Авито. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RealtyActionResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return ShortTermRentClient(self.transport).get_intervals( - item_id=item_id or int(self._require_item_id()), - intervals=intervals, + return self._execute( + GET_INTERVALS, + request=RealtyIntervalsRequest( + item_id=item_id or int(self._require_item_id()), + intervals=intervals, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -63,18 +98,41 @@ def get_intervals( method_args={"min_stay_days": "body.minimal_duration"}, ) def update_base_params( - self, *, min_stay_days: int, item_id: int | str | None = None + self, + *, + min_stay_days: int, + item_id: int | str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> RealtyActionResult: - """Выполняет публичную операцию `RealtyListing.update_base_params` и возвращает типизированную SDK-модель. + """Обновляет base params для посутчной аренды. + + Аргументы: + min_stay_days: задает минимальное число дней проживания. + item_id: идентифицирует объявление Авито. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RealtyActionResult` со статусом выполнения операции. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return ShortTermRentClient(self.transport).update_base_params( - item_id=item_id or self._require_item_id(), - min_stay_days=min_stay_days, + return self._execute( + UPDATE_BASE_PARAMS, + path_params={"item_id": item_id or self._require_item_id()}, + request=RealtyBaseParamsUpdateRequest(min_stay_days=min_stay_days), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) def _require_item_id(self) -> str: @@ -104,21 +162,49 @@ class RealtyBooking(DomainObject): def update_bookings_info( self, *, - blocked_dates: list[str], + blocked_dates: list[DateInput], user_id: int | str | None = None, item_id: int | str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> RealtyActionResult: - """Выполняет публичную операцию `RealtyBooking.update_bookings_info` и возвращает типизированную SDK-модель. + """Обновляет информацию о бронированиях недвижимости. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Аргументы: + blocked_dates: передает заблокированные даты бронирования. + user_id: идентифицирует пользователя или аккаунт Авито. + item_id: идентифицирует объявление Авито. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Возвращает: + `RealtyActionResult` со статусом выполнения операции. + + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - 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(), - blocked_dates=blocked_dates, + return self._execute( + UPDATE_BOOKINGS_INFO, + path_params={ + "user_id": user_id or self._require_user_id(), + "item_id": item_id or self._require_item_id(), + }, + request=RealtyBookingsUpdateRequest( + blocked_dates=[ + serialize_iso_date(f"blocked_dates[{index}]", blocked_date) + for index, blocked_date in enumerate(blocked_dates) + ] + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -131,27 +217,48 @@ def update_bookings_info( def list_realty_bookings( self, *, - date_start: str, - date_end: str, + date_start: DateInput, + date_end: DateInput, with_unpaid: bool | None = None, user_id: int | str | None = None, item_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> RealtyBookingsResult: - """Выполняет публичную операцию `RealtyBooking.list_realty_bookings` и возвращает типизированную SDK-модель. + """Возвращает список realty bookings для бронирований недвижимости. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Аргументы: + date_start: задает начальную дату периода бронирований. + date_end: задает конечную дату периода бронирований. + with_unpaid: включает неоплаченные бронирования в результат. + user_id: идентифицирует пользователя или аккаунт Авито. + item_id: идентифицирует объявление Авито. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Возвращает: + `RealtyBookingsResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return ShortTermRentClient(self.transport).list_realty_bookings( - user_id=user_id or self._require_user_id(), - item_id=item_id or self._require_item_id(), + return self._execute( + LIST_REALTY_BOOKINGS, + path_params={ + "user_id": user_id or self._require_user_id(), + "item_id": item_id or self._require_item_id(), + }, query=RealtyBookingsQuery( - date_start=date_start, - date_end=date_end, + date_start=serialize_iso_date("date_start", date_start), + date_end=serialize_iso_date("date_end", date_end), with_unpaid=with_unpaid, ), + timeout=timeout, + retry=retry, ) def _require_item_id(self) -> str: @@ -189,18 +296,41 @@ def update_realty_prices( periods: list[RealtyPricePeriod], user_id: int | str | None = None, item_id: int | str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> RealtyActionResult: - """Выполняет публичную операцию `RealtyPricing.update_realty_prices` и возвращает типизированную SDK-модель. + """Обновляет realty prices для цен недвижимости. + + Аргументы: + periods: передает периоды цен. + user_id: идентифицирует пользователя или аккаунт Авито. + item_id: идентифицирует объявление Авито. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Возвращает: + `RealtyActionResult` со статусом выполнения операции. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - 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(), - periods=periods, + return self._execute( + UPDATE_REALTY_PRICES, + path_params={ + "user_id": user_id or self._require_user_id(), + "item_id": item_id or self._require_item_id(), + }, + request=RealtyPricesUpdateRequest(periods=periods), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) def _require_item_id(self) -> str: @@ -237,17 +367,35 @@ def get_market_price_correspondence( *, item_id: int | str | None = None, price: int | str, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, ) -> RealtyMarketPriceInfo: - """Выполняет публичную операцию `RealtyAnalyticsReport.get_market_price_correspondence` и возвращает типизированную SDK-модель. + """Возвращает соответствие цены объявления рынку недвижимости. + + Аргументы: + item_id: идентифицирует объявление Авито. + price: передает цену для аналитического расчета. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RealtyMarketPriceInfo` с типизированными данными ответа API. - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return RealtyAnalyticsClient(self.transport).get_market_price_correspondence( - item_id=item_id or self._require_item_id(), - price=price, + return self._execute( + GET_MARKET_PRICE_CORRESPONDENCE, + path_params={ + "itemId": item_id or self._require_item_id(), + "price": price, + }, + timeout=timeout, + retry=retry, ) @swagger_operation( @@ -256,16 +404,39 @@ def get_market_price_correspondence( spec="Аналитикапонедвижимости.json", operation_id="CreateReportForClassified", ) - def get_report_for_classified(self, *, item_id: int | str | None = None) -> RealtyAnalyticsInfo: - """Выполняет публичную операцию `RealtyAnalyticsReport.get_report_for_classified` и возвращает типизированную SDK-модель. - - Пустой результат возвращается как пустая коллекция или `None` согласно аннотации метода. - - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + def get_report_for_classified( + self, + *, + item_id: int | str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> RealtyAnalyticsInfo: + """Возвращает аналитический отчет по объявлению недвижимости. + + Аргументы: + item_id: идентифицирует объявление Авито. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RealtyAnalyticsInfo` с типизированными данными ответа API. + + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return RealtyAnalyticsClient(self.transport).get_report_for_classified( - item_id=item_id or self._require_item_id() + return self._execute( + GET_REPORT_FOR_CLASSIFIED, + path_params={"itemId": item_id or self._require_item_id()}, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, ) def _require_item_id(self) -> str: diff --git a/avito/realty/enums.py b/avito/realty/enums.py deleted file mode 100644 index 70c5bd1..0000000 --- a/avito/realty/enums.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Enum-значения раздела realty.""" - -from __future__ import annotations - -from enum import Enum - - -class RealtyStatus(str, Enum): - """Статус сущности realty.""" - - UNKNOWN = "__unknown__" - ACTIVE = "active" - SUCCESS = "success" - CANCELED = "canceled" - PENDING = "pending" - - -class RealtyBookingStatus(str, Enum): - """Статус бронирования недвижимости.""" - - UNKNOWN = "__unknown__" - ACTIVE = "active" - CANCELED = "canceled" - PENDING = "pending" - - -class RealtyOperationStatus(str, Enum): - """Статус результата операции realty API.""" - - UNKNOWN = "__unknown__" - SUCCESS = "success" - - -__all__ = ("RealtyBookingStatus", "RealtyOperationStatus", "RealtyStatus") diff --git a/avito/realty/mappers.py b/avito/realty/mappers.py deleted file mode 100644 index ca58ebe..0000000 --- a/avito/realty/mappers.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Мапперы JSON -> dataclass для пакета realty.""" - -from __future__ import annotations - -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 RealtyBookingStatus, RealtyOperationStatus -from avito.realty.models import ( - RealtyActionResult, - RealtyAnalyticsInfo, - RealtyBookingContact, - RealtyBookingInfo, - RealtyBookingSafeDeposit, - RealtyBookingsResult, - RealtyMarketPriceInfo, -) - -Payload = Mapping[str, object] - - -def _expect_mapping(payload: object) -> Payload: - if not isinstance(payload, Mapping): - raise ResponseMappingError("Ожидался JSON-объект.", payload=payload) - return cast(Payload, payload) - - -def _mapping(payload: Payload, *keys: str) -> Payload: - for key in keys: - value = payload.get(key) - if isinstance(value, Mapping): - return cast(Payload, value) - return {} - - -def _list(payload: Payload, *keys: str) -> list[Payload]: - for key in keys: - value = payload.get(key) - if isinstance(value, list): - return [item for item in value if isinstance(item, Mapping)] - return [] - - -def _str(payload: Payload, *keys: str) -> str | None: - for key in keys: - value = payload.get(key) - if isinstance(value, str): - return value - if isinstance(value, int) and not isinstance(value, bool): - return str(value) - return None - - -def _int(payload: Payload, *keys: str) -> int | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - continue - if isinstance(value, int): - return value - return None - - -def map_action(payload: object) -> RealtyActionResult: - """Преобразует результат mutation-операции realty.""" - - data = _expect_mapping(payload) - return RealtyActionResult( - success=_str(data, "result") == "success" or bool(data.get("success", False)), - status=map_enum_or_unknown( - _str(data, "result", "status"), - RealtyOperationStatus, - enum_name="realty.operation_status", - ), - ) - - -def map_bookings(payload: object) -> RealtyBookingsResult: - """Преобразует список бронирований.""" - - data = _expect_mapping(payload) - return RealtyBookingsResult( - items=[ - RealtyBookingInfo( - booking_id=_int(item, "avito_booking_id", "id"), - base_price=_int(item, "base_price"), - check_in=_str(item, "check_in"), - check_out=_str(item, "check_out"), - contact=( - RealtyBookingContact( - name=_str(_mapping(item, "contact"), "name"), - email=_str(_mapping(item, "contact"), "email"), - phone=_str(_mapping(item, "contact"), "phone"), - ) - if isinstance(item.get("contact"), Mapping) - else None - ), - guest_count=_int(item, "guest_count"), - nights=_int(item, "nights"), - safe_deposit=( - RealtyBookingSafeDeposit( - owner_amount=_int(_mapping(item, "safe_deposit"), "owner_amount"), - tax=_int(_mapping(item, "safe_deposit"), "tax"), - total_amount=_int(_mapping(item, "safe_deposit"), "total_amount"), - ) - if isinstance(item.get("safe_deposit"), Mapping) - else None - ), - status=map_enum_or_unknown( - _str(item, "status"), - RealtyBookingStatus, - enum_name="realty.booking_status", - ), - ) - for item in _list(data, "bookings", "items") - ], - ) - - -def map_market_price(payload: object) -> RealtyMarketPriceInfo: - """Преобразует соответствие цены рыночной.""" - - data = _expect_mapping(payload) - return RealtyMarketPriceInfo( - correspondence=_str(data, "correspondence"), - error_message=_str(_mapping(data, "error"), "message"), - ) - - -def map_analytics_report(payload: object) -> RealtyAnalyticsInfo: - """Преобразует ответ аналитического отчета.""" - - data = _expect_mapping(payload) - success_mapping = _mapping(data, "success") - success_data = _mapping(success_mapping, "success") - errors = _mapping(success_mapping, "errors") - return RealtyAnalyticsInfo( - success=bool(success_data) or _str(_mapping(data, "result"), "result") == "success", - report_link=_str(success_data, "reportLink"), - error_message=_str(errors, "message"), - ) diff --git a/avito/realty/models.py b/avito/realty/models.py index 6be3f1a..d3d61f6 100644 --- a/avito/realty/models.py +++ b/avito/realty/models.py @@ -2,34 +2,88 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass +from enum import Enum -from avito.core.serialization import SerializableModel -from avito.realty.enums import RealtyBookingStatus, RealtyOperationStatus +from avito.core import ApiModel, JsonReader, RequestModel +from avito.core.validation import DateInput, serialize_iso_date + + +class RealtyStatus(str, Enum): + """Статус сущности realty.""" + + UNKNOWN = "__unknown__" + ACTIVE = "active" + SUCCESS = "success" + CANCELED = "canceled" + PENDING = "pending" + + +class RealtyBookingStatus(str, Enum): + """Статус бронирования недвижимости.""" + + UNKNOWN = "__unknown__" + ACTIVE = "active" + CANCELED = "canceled" + PENDING = "pending" + + +class RealtyOperationStatus(str, Enum): + """Статус результата операции realty API.""" + + UNKNOWN = "__unknown__" + SUCCESS = "success" @dataclass(slots=True, frozen=True) -class RealtyActionResult(SerializableModel): +class RealtyActionResult(ApiModel): """Результат mutation-операции по недвижимости.""" success: bool status: RealtyOperationStatus | None = None + @classmethod + def from_payload(cls, payload: object) -> RealtyActionResult: + """Преобразует результат mutation-операции realty.""" + + data = JsonReader.expect_mapping(payload) + reader = JsonReader(payload) + result = _optional_str_or_int(data, "result") + status = reader.enum( + RealtyOperationStatus, + "result", + "status", + unknown=RealtyOperationStatus.UNKNOWN, + ) + return cls( + success=result == "success" or bool(data.get("success", False)), + status=status, + ) + @dataclass(slots=True, frozen=True) -class RealtyBookingsUpdateRequest: +class RealtyBookingsUpdateRequest(RequestModel): """Запрос обновления занятости по объекту.""" - blocked_dates: list[str] + blocked_dates: list[DateInput] def to_payload(self) -> dict[str, object]: """Сериализует JSON payload запроса бронирований.""" - return {"blockedDates": list(self.blocked_dates)} + return { + "bookings": [ + {"date_start": date_value, "date_end": date_value} + for date_value in ( + serialize_iso_date(f"blocked_dates[{index}]", blocked_date) + for index, blocked_date in enumerate(self.blocked_dates) + ) + ] + } @dataclass(slots=True, frozen=True) -class RealtyBookingSafeDeposit(SerializableModel): +class RealtyBookingSafeDeposit(ApiModel): """Информация о предоплате по бронированию.""" owner_amount: int | None @@ -38,7 +92,7 @@ class RealtyBookingSafeDeposit(SerializableModel): @dataclass(slots=True, frozen=True) -class RealtyBookingContact(SerializableModel): +class RealtyBookingContact(ApiModel): """Контактные данные гостя.""" name: str | None @@ -47,7 +101,7 @@ class RealtyBookingContact(SerializableModel): @dataclass(slots=True, frozen=True) -class RealtyBookingInfo(SerializableModel): +class RealtyBookingInfo(ApiModel): """Информация о бронировании объекта недвижимости.""" booking_id: int | None @@ -60,46 +114,102 @@ class RealtyBookingInfo(SerializableModel): safe_deposit: RealtyBookingSafeDeposit | None status: RealtyBookingStatus | None + @classmethod + def from_payload(cls, payload: object) -> RealtyBookingInfo: + """Преобразует JSON-объект бронирования в SDK-модель.""" + + data = JsonReader.expect_mapping(payload) + reader = JsonReader(payload) + contact = reader.mapping("contact") + safe_deposit = reader.mapping("safe_deposit") + return cls( + booking_id=_optional_int(data, "avito_booking_id", "id"), + base_price=reader.optional_int("base_price"), + check_in=reader.optional_str("check_in"), + check_out=reader.optional_str("check_out"), + contact=( + RealtyBookingContact( + name=JsonReader(contact).optional_str("name"), + email=JsonReader(contact).optional_str("email"), + phone=JsonReader(contact).optional_str("phone"), + ) + if contact is not None + else None + ), + guest_count=reader.optional_int("guest_count"), + nights=reader.optional_int("nights"), + safe_deposit=( + RealtyBookingSafeDeposit( + owner_amount=JsonReader(safe_deposit).optional_int("owner_amount"), + tax=JsonReader(safe_deposit).optional_int("tax"), + total_amount=JsonReader(safe_deposit).optional_int("total_amount"), + ) + if safe_deposit is not None + else None + ), + status=reader.enum( + RealtyBookingStatus, + "status", + unknown=RealtyBookingStatus.UNKNOWN, + ), + ) + @dataclass(slots=True, frozen=True) -class RealtyBookingsResult(SerializableModel): +class RealtyBookingsResult(ApiModel): """Список бронирований по объявлению.""" items: list[RealtyBookingInfo] + @classmethod + def from_payload(cls, payload: object) -> RealtyBookingsResult: + """Преобразует список бронирований.""" + + reader = JsonReader(payload) + return cls( + items=[ + RealtyBookingInfo.from_payload(item) + for item in reader.list("bookings", "items") + if isinstance(item, Mapping) + ], + ) + @dataclass(slots=True, frozen=True) -class RealtyBookingsQuery: +class RealtyBookingsQuery(RequestModel): """Query-параметры запроса бронирований.""" date_start: str date_end: str with_unpaid: bool | None = None - def to_params(self) -> dict[str, str]: + def to_params(self) -> dict[str, object]: """Сериализует query-параметры запроса бронирований.""" - params = {"date_start": self.date_start, "date_end": self.date_end} + params: dict[str, object] = { + "date_start": self.date_start, + "date_end": self.date_end, + } if self.with_unpaid is not None: params["with_unpaid"] = "true" if self.with_unpaid else "false" return params @dataclass(slots=True, frozen=True) -class RealtyPricePeriod: +class RealtyPricePeriod(RequestModel): """Период с ценой в запросе обновления цен.""" - date_from: str + date_from: DateInput price: int def to_payload(self) -> dict[str, object]: """Сериализует период с ценой.""" - return {"dateFrom": self.date_from, "price": self.price} + return {"date_from": serialize_iso_date("date_from", self.date_from), "night_price": self.price} @dataclass(slots=True, frozen=True) -class RealtyPricesUpdateRequest: +class RealtyPricesUpdateRequest(RequestModel): """Запрос обновления цен по объекту.""" periods: list[RealtyPricePeriod] @@ -107,24 +217,29 @@ class RealtyPricesUpdateRequest: def to_payload(self) -> dict[str, object]: """Сериализует JSON payload запроса цен.""" - return {"periods": [period.to_payload() for period in self.periods]} + return {"prices": [period.to_payload() for period in self.periods]} @dataclass(slots=True, frozen=True) -class RealtyInterval: +class RealtyInterval(RequestModel): """Интервал доступности объекта.""" - date: str + date: DateInput available: bool def to_payload(self) -> dict[str, object]: """Сериализует интервал доступности.""" - return {"date": self.date, "available": self.available} + date_value = serialize_iso_date("date", self.date) + return { + "date_start": date_value, + "date_end": date_value, + "open": 1 if self.available else 0, + } @dataclass(slots=True, frozen=True) -class RealtyIntervalsRequest: +class RealtyIntervalsRequest(RequestModel): """Запрос заполнения интервалов доступности.""" item_id: int @@ -134,13 +249,13 @@ def to_payload(self) -> dict[str, object]: """Сериализует JSON payload запроса интервалов.""" return { - "itemId": self.item_id, + "item_id": self.item_id, "intervals": [interval.to_payload() for interval in self.intervals], } @dataclass(slots=True, frozen=True) -class RealtyBaseParamsUpdateRequest: +class RealtyBaseParamsUpdateRequest(RequestModel): """Запрос обновления базовых параметров объекта.""" min_stay_days: int @@ -148,21 +263,88 @@ class RealtyBaseParamsUpdateRequest: def to_payload(self) -> dict[str, object]: """Сериализует JSON payload запроса базовых параметров.""" - return {"minStayDays": self.min_stay_days} + return {"minimal_duration": self.min_stay_days} @dataclass(slots=True, frozen=True) -class RealtyMarketPriceInfo(SerializableModel): +class RealtyMarketPriceInfo(ApiModel): """Соответствие цены рыночной стоимости.""" correspondence: str | None error_message: str | None = None + @classmethod + def from_payload(cls, payload: object) -> RealtyMarketPriceInfo: + """Преобразует соответствие цены рыночной.""" + + reader = JsonReader(payload) + error = reader.mapping("error") + return cls( + correspondence=reader.optional_str("correspondence"), + error_message=JsonReader(error).optional_str("message") if error is not None else None, + ) + @dataclass(slots=True, frozen=True) -class RealtyAnalyticsInfo(SerializableModel): +class RealtyAnalyticsInfo(ApiModel): """Информация об аналитическом отчете по недвижимости.""" success: bool report_link: str | None = None error_message: str | None = None + + @classmethod + def from_payload(cls, payload: object) -> RealtyAnalyticsInfo: + """Преобразует ответ аналитического отчета.""" + + reader = JsonReader(payload) + success_mapping = reader.mapping("success") or {} + success_data = JsonReader(success_mapping).mapping("success") or {} + errors = JsonReader(success_mapping).mapping("errors") or {} + result = reader.mapping("result") or {} + return cls( + success=bool(success_data) or JsonReader(result).optional_str("result") == "success", + report_link=JsonReader(success_data).optional_str("reportLink"), + error_message=JsonReader(errors).optional_str("message"), + ) + + +def _optional_str_or_int(payload: Mapping[str, object], *keys: str) -> str | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str): + return value + if isinstance(value, int) and not isinstance(value, bool): + return str(value) + return None + + +def _optional_int(payload: Mapping[str, object], *keys: str) -> int | None: + for key in keys: + value = payload.get(key) + if isinstance(value, bool): + continue + if isinstance(value, int): + return value + return None + + +__all__ = ( + "RealtyActionResult", + "RealtyAnalyticsInfo", + "RealtyBaseParamsUpdateRequest", + "RealtyBookingContact", + "RealtyBookingInfo", + "RealtyBookingSafeDeposit", + "RealtyBookingStatus", + "RealtyBookingsQuery", + "RealtyBookingsResult", + "RealtyBookingsUpdateRequest", + "RealtyInterval", + "RealtyIntervalsRequest", + "RealtyMarketPriceInfo", + "RealtyOperationStatus", + "RealtyPricePeriod", + "RealtyPricesUpdateRequest", + "RealtyStatus", +) diff --git a/avito/realty/operations.py b/avito/realty/operations.py new file mode 100644 index 0000000..837b991 --- /dev/null +++ b/avito/realty/operations.py @@ -0,0 +1,79 @@ +"""Operation specs for realty domain.""" + +from __future__ import annotations + +from avito.core import OperationSpec +from avito.realty.models import ( + RealtyActionResult, + RealtyAnalyticsInfo, + RealtyBaseParamsUpdateRequest, + RealtyBookingsQuery, + RealtyBookingsResult, + RealtyBookingsUpdateRequest, + RealtyIntervalsRequest, + RealtyMarketPriceInfo, + RealtyPricesUpdateRequest, +) + +UPDATE_BOOKINGS_INFO = OperationSpec( + name="realty.bookings.update", + method="POST", + path="/core/v1/accounts/{user_id}/items/{item_id}/bookings", + request_model=RealtyBookingsUpdateRequest, + response_model=RealtyActionResult, + retry_mode="enabled", +) +LIST_REALTY_BOOKINGS = OperationSpec( + name="realty.bookings.list", + method="GET", + path="/realty/v1/accounts/{user_id}/items/{item_id}/bookings", + query_model=RealtyBookingsQuery, + response_model=RealtyBookingsResult, +) +UPDATE_REALTY_PRICES = OperationSpec( + name="realty.prices.update", + method="POST", + path="/realty/v1/accounts/{user_id}/items/{item_id}/prices", + request_model=RealtyPricesUpdateRequest, + response_model=RealtyActionResult, + retry_mode="enabled", +) +GET_INTERVALS = OperationSpec( + name="realty.intervals.fill", + method="POST", + path="/realty/v1/items/intervals", + request_model=RealtyIntervalsRequest, + response_model=RealtyActionResult, + retry_mode="enabled", +) +UPDATE_BASE_PARAMS = OperationSpec( + name="realty.base_params.update", + method="POST", + path="/realty/v1/items/{item_id}/base", + request_model=RealtyBaseParamsUpdateRequest, + response_model=RealtyActionResult, + retry_mode="enabled", +) +GET_MARKET_PRICE_CORRESPONDENCE = OperationSpec( + name="realty.analytics.market_price", + method="GET", + path="/realty/v1/marketPriceCorrespondence/{itemId}/{price}", + response_model=RealtyMarketPriceInfo, +) +GET_REPORT_FOR_CLASSIFIED = OperationSpec( + name="realty.analytics.report", + method="POST", + path="/realty/v1/report/create/{itemId}", + response_model=RealtyAnalyticsInfo, + retry_mode="enabled", +) + +__all__ = ( + "GET_INTERVALS", + "GET_MARKET_PRICE_CORRESPONDENCE", + "GET_REPORT_FOR_CLASSIFIED", + "LIST_REALTY_BOOKINGS", + "UPDATE_BASE_PARAMS", + "UPDATE_BOOKINGS_INFO", + "UPDATE_REALTY_PRICES", +) diff --git a/avito/summary/models.py b/avito/summary/models.py index 61f0c55..6979181 100644 --- a/avito/summary/models.py +++ b/avito/summary/models.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field -from avito.ads.enums import ListingStatus +from avito.ads.models import ListingStatus from avito.core.serialization import SerializableModel diff --git a/avito/tariffs/__init__.py b/avito/tariffs/__init__.py index a207a09..87a5aac 100644 --- a/avito/tariffs/__init__.py +++ b/avito/tariffs/__init__.py @@ -1,7 +1,6 @@ """Пакет tariffs.""" from avito.tariffs.domain import Tariff -from avito.tariffs.enums import TariffLevel -from avito.tariffs.models import TariffContractInfo, TariffInfo +from avito.tariffs.models import TariffContractInfo, TariffInfo, TariffLevel __all__ = ("Tariff", "TariffContractInfo", "TariffInfo", "TariffLevel") diff --git a/avito/tariffs/client.py b/avito/tariffs/client.py deleted file mode 100644 index c85e8ae..0000000 --- a/avito/tariffs/client.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Внутренние section clients для пакета tariffs.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from avito.core import RequestContext, Transport -from avito.core.mapping import request_public_model -from avito.tariffs.mappers import map_tariff_info -from avito.tariffs.models import TariffInfo - - -@dataclass(slots=True, frozen=True) -class TariffsClient: - """Выполняет HTTP-операции тарифов.""" - - transport: Transport - - def get_tariff_info(self) -> TariffInfo: - return request_public_model( - self.transport, - "GET", - "/tariff/info/1", - context=RequestContext("tariffs.info.get"), - mapper=map_tariff_info, - ) diff --git a/avito/tariffs/domain.py b/avito/tariffs/domain.py index 37e46a3..2ad5381 100644 --- a/avito/tariffs/domain.py +++ b/avito/tariffs/domain.py @@ -4,10 +4,11 @@ from dataclasses import dataclass +from avito.core import ApiTimeouts, RetryOverride from avito.core.domain import DomainObject from avito.core.swagger import swagger_operation -from avito.tariffs.client import TariffsClient from avito.tariffs.models import TariffInfo +from avito.tariffs.operations import GET_TARIFF_INFO @dataclass(slots=True, frozen=True) @@ -26,13 +27,26 @@ class Tariff(DomainObject): spec="Тарифы.json", operation_id="getTariffInfo", ) - def get_tariff_info(self) -> TariffInfo: + def get_tariff_info( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> TariffInfo: """Получает информацию о тарифе аккаунта. - Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `TariffInfo` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. """ - return TariffsClient(self.transport).get_tariff_info() + return self._execute(GET_TARIFF_INFO, timeout=timeout, retry=retry) __all__ = ("Tariff",) diff --git a/avito/tariffs/enums.py b/avito/tariffs/enums.py deleted file mode 100644 index 071c223..0000000 --- a/avito/tariffs/enums.py +++ /dev/null @@ -1,16 +0,0 @@ -"""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 deleted file mode 100644 index c96f478..0000000 --- a/avito/tariffs/mappers.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Мапперы JSON -> dataclass для пакета tariffs.""" - -from __future__ import annotations - -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] - - -def _expect_mapping(payload: object) -> Payload: - if not isinstance(payload, Mapping): - raise ResponseMappingError("Ожидался JSON-объект.", payload=payload) - return cast(Payload, payload) - - -def _mapping(payload: Payload, *keys: str) -> Payload: - for key in keys: - value = payload.get(key) - if isinstance(value, Mapping): - return cast(Payload, value) - return {} - - -def _str(payload: Payload, *keys: str) -> str | None: - for key in keys: - value = payload.get(key) - if isinstance(value, str): - return value - return None - - -def _int(payload: Payload, *keys: str) -> int | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - continue - if isinstance(value, int): - return value - return None - - -def _bool(payload: Payload, *keys: str) -> bool | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - return value - return None - - -def _float(payload: Payload, *keys: str) -> float | None: - for key in keys: - value = payload.get(key) - if isinstance(value, bool): - continue - if isinstance(value, (int, float)): - return float(value) - return None - - -def _map_contract(payload: Payload) -> TariffContractInfo | None: - if not payload: - return None - price = _mapping(payload, "price") - packages = payload.get("packages") - packages_count = len(packages) if isinstance(packages, list) else None - return TariffContractInfo( - 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"), - bonus=_int(payload, "bonus"), - price=_float(price, "price"), - original_price=_float(price, "originalPrice"), - packages_count=packages_count, - ) - - -def map_tariff_info(payload: object) -> TariffInfo: - """Преобразует информацию о тарифе.""" - - data = _expect_mapping(payload) - return TariffInfo( - current=_map_contract(_mapping(data, "current")), - scheduled=_map_contract(_mapping(data, "scheduled")), - ) diff --git a/avito/tariffs/models.py b/avito/tariffs/models.py index c3cb7d2..838e48a 100644 --- a/avito/tariffs/models.py +++ b/avito/tariffs/models.py @@ -2,14 +2,24 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass +from enum import Enum +from typing import cast -from avito.core.serialization import SerializableModel -from avito.tariffs.enums import TariffLevel +from avito.core import ApiModel, JsonReader + + +class TariffLevel(str, Enum): + """Уровень тарифного контракта.""" + + UNKNOWN = "__unknown__" + MAX = "Тариф Максимальный" + BASE = "Тариф Базовый" @dataclass(slots=True, frozen=True) -class TariffContractInfo(SerializableModel): +class TariffContractInfo(ApiModel): """Информация о текущем или запланированном тарифном контракте.""" level: TariffLevel | None @@ -21,10 +31,48 @@ class TariffContractInfo(SerializableModel): original_price: float | None packages_count: int | None + @classmethod + def from_payload(cls, payload: object) -> TariffContractInfo: + """Преобразует JSON-объект контракта тарифа в SDK-модель.""" + + reader = JsonReader(payload) + price = reader.mapping("price") or {} + packages = reader.list("packages") + price_reader = JsonReader(price) + return cls( + level=reader.enum(TariffLevel, "level", unknown=TariffLevel.UNKNOWN), + is_active=reader.optional_bool("isActive"), + start_time=reader.optional_int("startTime"), + close_time=reader.optional_int("closeTime"), + bonus=reader.optional_int("bonus"), + price=price_reader.optional_float("price"), + original_price=price_reader.optional_float("originalPrice"), + packages_count=len(packages) if packages else None, + ) + @dataclass(slots=True, frozen=True) -class TariffInfo(SerializableModel): +class TariffInfo(ApiModel): """Информация по текущему и запланированному тарифу.""" current: TariffContractInfo | None = None scheduled: TariffContractInfo | None = None + + @classmethod + def from_payload(cls, payload: object) -> TariffInfo: + """Преобразует ответ API с информацией о тарифе в SDK-модель.""" + + reader = JsonReader(payload) + return cls( + current=_contract_from_mapping(reader.mapping("current")), + scheduled=_contract_from_mapping(reader.mapping("scheduled")), + ) + + +def _contract_from_mapping(payload: Mapping[str, object] | None) -> TariffContractInfo | None: + if not payload: + return None + return TariffContractInfo.from_payload(cast(object, payload)) + + +__all__ = ("TariffContractInfo", "TariffInfo", "TariffLevel") diff --git a/avito/tariffs/operations.py b/avito/tariffs/operations.py new file mode 100644 index 0000000..4495ca1 --- /dev/null +++ b/avito/tariffs/operations.py @@ -0,0 +1,15 @@ +"""Operation specs for tariffs domain.""" + +from __future__ import annotations + +from avito.core import OperationSpec +from avito.tariffs.models import TariffInfo + +GET_TARIFF_INFO = OperationSpec( + name="tariffs.info.get", + method="GET", + path="/tariff/info/1", + response_model=TariffInfo, +) + +__all__ = ("GET_TARIFF_INFO",) diff --git a/avito/testing/__init__.py b/avito/testing/__init__.py index b6178eb..cab024c 100644 --- a/avito/testing/__init__.py +++ b/avito/testing/__init__.py @@ -13,6 +13,7 @@ SwaggerRoute, error_payload, ) +from avito.testing.swagger_schema import generate_schema_value, validate_schema_value __all__ = ( "FakeTransport", @@ -22,6 +23,8 @@ "SwaggerFakeTransport", "SwaggerRoute", "error_payload", + "generate_schema_value", "json_response", "route_sequence", + "validate_schema_value", ) diff --git a/avito/testing/swagger_fake_transport.py b/avito/testing/swagger_fake_transport.py index d3d570d..732b96c 100644 --- a/avito/testing/swagger_fake_transport.py +++ b/avito/testing/swagger_fake_transport.py @@ -5,10 +5,13 @@ import inspect import json import re -from collections.abc import Callable, Mapping -from dataclasses import dataclass -from datetime import UTC, datetime -from typing import TYPE_CHECKING, cast +from collections.abc import Callable, Mapping, Sequence +from dataclasses import MISSING as DATACLASS_MISSING +from dataclasses import dataclass, fields, is_dataclass +from datetime import UTC, date, datetime +from enum import Enum +from types import UnionType +from typing import Union, cast, get_args, get_origin, get_type_hints from urllib.parse import parse_qs import httpx @@ -18,25 +21,41 @@ from avito.auth.provider import AlternateTokenClient, TokenClient from avito.client import AvitoClient from avito.core.swagger_discovery import DiscoveredSwaggerBinding -from avito.core.swagger_registry import SwaggerOperation, SwaggerRegistry, SwaggerResponse +from avito.core.swagger_names import swagger_field_aliases +from avito.core.swagger_registry import ( + SwaggerOperation, + SwaggerRegistry, + SwaggerResponse, + SwaggerSchema, +) +from avito.core.swagger_schema_paths import ( + SwaggerBodyPath, + SwaggerSchemaPathError, + resolve_body_path, +) from avito.testing.fake_transport import FakeTransport, JsonValue, RecordedRequest -if TYPE_CHECKING: - from avito.orders.models import DeliveryAddress, DeliveryRestriction, WeeklySchedule - SdkValue = object +_MISSING = object() _PATH_PARAMETER_RE = re.compile(r"{([A-Za-z_][A-Za-z0-9_]*)}") +_NAME_ALIASES: Mapping[str, tuple[str, ...]] = { + "campaignId": ("campaign_id",), + "minimal_duration": ("min_stay_days",), +} _SDK_CONSTANTS: Mapping[str, SdkValue] = { "account_id": 7, "action_id": 101, "call_id": 102, "campaign_id": 103, + "category_id": 1, "chat_id": "chat-1", "delivery_provider_id": "provider-1", "dictionary_id": 104, "employee_id": 10, "grant_type": "client_credentials", + "grouping": "day", + "is_enabled": True, "item_id": 105, "item_ids": [105], "limit": 2, @@ -57,7 +76,9 @@ "vacancy_id": 113, "vacancy_uuid": "vacancy-uuid-1", "value": "value", - "vehicle_id": 114, + "subscriptionId": 1, + "vehicle_id": "XTA210990Y2766384", + "version": 1, "voice_ids": ["voice-1"], } _BODY_VALUES: Mapping[str, SdkValue] = { @@ -67,33 +88,45 @@ "action_ids": [101], "applies": [], "auto_renewal": True, + "autoload_enabled": True, "bid_penny": 1000, "billing_type": "package", "blacklisted_user_id": 7, - "blocked_dates": [{"date": "2026-05-01"}], + "blocked_dates": ["2026-05-01"], "brand_id": 1, "budget_penny": 1000, "budget_type": "daily", + "business_area": 7, "call_id": 102, "campaign_id": 103, "code": "1234", "codes": ["xl"], "date_time_from": "2026-04-01T00:00:00+00:00", "date_time_to": "2026-04-02T00:00:00+00:00", + "dateFrom": "2026-04-01", + "dateTo": "2026-04-02", + "description": "Описание вакансии", + "dispatch_id": 1, "employee_id": 10, + "employment": "full", + "experience": "noMatter", "files": ["file-1"], + "grouping": "day", + "is_enabled": True, "ids": [101], "image_id": "image-1", - "intervals": [{"date_from": "2026-05-01", "date_to": "2026-05-02"}], + "intervals": [], "item_id": 105, "item_ids": [105], "limit": 2, "message": "Тестовое сообщение", + "metrics": ["views"], "mileage": 10000, "min_stay_days": 2, "name": "Тариф", "order_id": 106, "package_code": "xl", + "offer_slug": "discount", "periods": [{"date_from": "2026-05-01", "date_to": "2026-05-02"}], "pickup_point_id": 1, "plate_number": "А123АА77", @@ -102,14 +135,22 @@ "price": 1500, "reason": "test", "reg_number": "А123АА77", + "report_email": "autoload@example.test", + "recipients_count": 1, + "schedule": "fixed", + "schedule_rate": 100, + "secret": "cb1e150b-c5bf-4c3e-acd1-20ec88bdb3a1", "specification_id": 1, + "spendingTypes": ["promotion"], + "slugs": ["xl"], "task_id": 112, "text": "Ответ", "title": "Тест", "transition": "confirm", "url": "https://example.test/file.xml", "vacancy_id": 113, - "vehicle_id": 114, + "vacancy_schedule": "fixed", + "vehicle_id": "XTA210990Y2766384", "vehicles": [{"vin": "XTA210990Y2766384"}], "vin": "XTA210990Y2766384", } @@ -194,7 +235,7 @@ def invoke_binding( """Build and invoke SDK call from discovered Swagger binding metadata.""" if binding.operation_key is None: - raise AssertionError(f"Binding ambiguous: {binding.sdk_method}") + raise AssertionError(f"Привязка Swagger неоднозначна: {binding.sdk_method}") if binding.domain == "auth": target = self._build_auth_target(binding) method = getattr(target, binding.method_name) @@ -264,6 +305,7 @@ def _build_arguments( callable_object: Callable[..., object], ) -> dict[str, object]: signature = inspect.signature(callable_object) + type_hints = _callable_type_hints(callable_object) arguments = {} for argument_name, expression in mapping.items(): parameter = signature.parameters.get(argument_name) @@ -271,6 +313,7 @@ def _build_arguments( argument_name, expression, parameter, + type_hints.get(argument_name), ) for name, parameter in signature.parameters.items(): if name == "self" or name in arguments: @@ -279,7 +322,12 @@ def _build_arguments( parameter.default is inspect.Parameter.empty or self._should_supply_optional_argument(name, parameter) ): - arguments[name] = self._value_for_argument(name, f"constant.{name}", parameter) + arguments[name] = self._value_for_argument( + name, + f"constant.{name}", + parameter, + type_hints.get(name), + ) return arguments def _value_for_argument( @@ -287,6 +335,7 @@ def _value_for_argument( argument_name: str, expression: str, parameter: inspect.Parameter | None, + annotation_type: object | None, ) -> object: annotation = _annotation_name(parameter) if "ClientCredentialsRequest" in annotation: @@ -316,8 +365,18 @@ def _value_for_argument( ) ] if expression == "body": - return self._body_value(argument_name, annotation) - return self._value_for_expression(expression, argument_name=argument_name, annotation=annotation) + return self._body_value( + argument_name, + annotation, + annotation_type, + self._current_request_body_schema(), + ) + return self._value_for_expression( + expression, + argument_name=argument_name, + annotation=annotation, + annotation_type=annotation_type, + ) def _value_for_expression( self, @@ -325,16 +384,22 @@ def _value_for_expression( *, argument_name: str, annotation: str, + annotation_type: object | None, ) -> object: if expression == "body": - return self._body_value(argument_name, annotation) + return self._body_value( + argument_name, + annotation, + annotation_type, + self._current_request_body_schema(), + ) prefix, separator, field_name = expression.partition(".") if not separator: raise AssertionError(f"Некорректное binding expression: {expression}") if prefix in {"path", "query", "header", "constant"}: return self._value_for_name(field_name) if prefix == "body": - return self._body_field_value(argument_name, field_name, annotation) + return self._body_path_value(argument_name, field_name, annotation, annotation_type) raise AssertionError(f"Неподдерживаемое binding expression: {expression}") def _query_value(self, annotation: str) -> object: @@ -360,131 +425,152 @@ def _query_value(self, annotation: str) -> object: return ReviewsQuery(offset=0, limit=10) return self._value_for_name("query") - def _body_value(self, argument_name: str, annotation: str) -> object: - if "SandboxArea" in annotation: - from avito.orders.models import SandboxArea - - return [SandboxArea(city="Москва")] - if "SortingCenterUpload" in annotation: - return [self._sorting_center_upload()] - if "TaggedSortingCenter" in annotation: - from avito.orders.models import TaggedSortingCenter - - return [TaggedSortingCenter(delivery_provider_id="provider-1", direction_tag="tag-1")] - if "TerminalUpload" in annotation: - return [self._terminal_upload()] - if "DeliveryTermsZone" in annotation: - from avito.orders.models import DeliveryTermsZone - - return [DeliveryTermsZone(delivery_provider_zone_id="zone-1", min_term=1, max_term=2)] - if "StockUpdateEntry" in annotation: - from avito.orders.models import StockUpdateEntry - - return [StockUpdateEntry(item_id=105, quantity=5)] - return self._body_field_value(argument_name, argument_name, annotation) - - def _body_field_value(self, argument_name: str, field_name: str, annotation: str) -> object: - if argument_name == "applies" or "ApplicationViewedItem" in annotation: - from avito.jobs.models import ApplicationViewedItem - - return [ApplicationViewedItem(id="apply-1", is_viewed=True)] - if "BbipItemInput" in annotation: - return [{"item_id": 105, "duration": 7, "price": 1500, "old_price": 2000}] - if "TrxItemInput" in annotation: - return [ - { - "item_id": 105, - "commission": 10, - "date_from": datetime(2026, 5, 1, tzinfo=UTC), - } - ] - if "BidItemInput" in annotation: - return [{"item_id": 105, "price_penny": 1000}] - if "StockUpdateEntry" in annotation: - from avito.orders.models import StockUpdateEntry - - return [StockUpdateEntry(item_id=105, quantity=5)] - if field_name == "directions": - from avito.orders.models import DeliveryDirection, DeliveryDirectionZone + def _body_value( + self, + argument_name: str, + annotation: str, + annotation_type: object | None, + schema: SwaggerSchema | None, + ) -> object: + value = self._value_for_type(argument_name, annotation_type, schema) + if value is not _MISSING: + return value + return self._body_field_value(argument_name, argument_name, annotation, annotation_type, schema) - return [ - DeliveryDirection( - provider_direction_id="direction-1", - tag_from="from", - tag_to="to", - zones=[DeliveryDirectionZone(tariff_zone_id="tariff-zone-1")], - ) - ] - if field_name == "tariff_zones": - from avito.orders.models import ( - DeliveryTariffItem, - DeliveryTariffValue, - DeliveryTariffZone, - ) + def _body_path_value( + self, + argument_name: str, + path: str, + annotation: str, + annotation_type: object | None, + ) -> object: + binding = self._binding_body_path(path) + if "datetime" in annotation: + return datetime(2026, 5, 1, tzinfo=UTC) + if _is_nested_body_path(binding): + value = _known_value(argument_name) + if value is not _MISSING: + return _coerce_schema_value(value, binding) + value = _known_value(*_name_aliases(binding.leaf_name)) + if value is not _MISSING: + return _coerce_schema_value(value, binding) + return _coerce_schema_value( + self._body_field_value( + argument_name, + binding.leaf_name, + annotation, + annotation_type, + binding.leaf_schema, + ), + binding, + ) - return [ - DeliveryTariffZone( - name="Зона", - delivery_provider_zone_id="zone-1", - items=[ - DeliveryTariffItem( - calculation_mechanic="fixed", - chargeable_parameter="weight", - service_name="delivery", - values=[DeliveryTariffValue(cost=100)], - ) - ], - ) - ] - if field_name == "terms_zones": - from avito.orders.models import DeliveryTermsZone + def _binding_body_path(self, path: str) -> SwaggerBodyPath: + for route in self._swagger_routes.values(): + request_body = route.operation.request_body + if request_body is None or request_body.schema is None: + continue + try: + return resolve_body_path(request_body.schema, path) + except SwaggerSchemaPathError: + continue + raise AssertionError(f"Swagger body path не найден: body.{path}") - return [DeliveryTermsZone(delivery_provider_zone_id="zone-1", min_term=1, max_term=2)] - if field_name == "periods" or "RealtyPricePeriod" in annotation: - from avito.realty.models import RealtyPricePeriod + def _current_request_body_schema(self) -> SwaggerSchema | None: + for route in self._swagger_routes.values(): + if route.operation.request_body is not None: + return route.operation.request_body.schema + return None - return [RealtyPricePeriod(date_from="2026-05-01", price=1500)] - if "SandboxCancelAnnouncementOptions" in annotation: - from avito.orders.models import SandboxCancelAnnouncementOptions + def _body_field_value( + self, + argument_name: str, + field_name: str, + annotation: str, + annotation_type: object | None, + schema: SwaggerSchema | None, + ) -> object: + if field_name == "ids" and "str" in annotation: + return ["id-1"] + value = _known_value(*_name_aliases(field_name)) + if value is not _MISSING: + return value + typed_value = self._value_for_type(field_name, annotation_type, schema) + if typed_value is not _MISSING: + return typed_value + if "datetime" in annotation: + return datetime(2026, 5, 1, tzinfo=UTC) + return self._value_for_name(field_name) - return SandboxCancelAnnouncementOptions( - url_to_cancel_announcement="https://example.test/cancel" - ) - if field_name == "sender" or "SandboxAnnouncementParticipant" in annotation: - return self._sandbox_participant("sender") - if field_name == "receiver": - return self._sandbox_participant("receiver") - if field_name == "packages" or "SandboxAnnouncementPackage" in annotation: - from avito.orders.models import SandboxAnnouncementPackage - - return [SandboxAnnouncementPackage(package_id="package-1", parcel_ids=["parcel-1"])] - if "SandboxCreateAnnouncementOptions" in annotation: - from avito.orders.models import SandboxCreateAnnouncementOptions - - return SandboxCreateAnnouncementOptions( - url_to_send_announcement="https://example.test/send" + def _value_for_type( + self, + name: str, + annotation_type: object | None, + schema: SwaggerSchema | None, + ) -> object: + if annotation_type is None: + return _MISSING + annotation_type = _unwrap_optional(annotation_type) + origin = get_origin(annotation_type) + if origin in {list, Sequence}: + item_type = _first_type_arg(annotation_type) + item_schema = schema.items if schema is not None and schema.is_array else None + item = self._value_for_type(_singular_name(name), item_type, item_schema) + if item is not _MISSING: + return [item] + return _MISSING + if annotation_type is datetime: + return datetime(2026, 5, 1, tzinfo=UTC) + if _is_date_input_type(annotation_type): + value = _dateish_value(name) + return "2026-05-01" if value is _MISSING else value + if inspect.isclass(annotation_type) and issubclass(annotation_type, Enum): + enum_values = list(annotation_type) + if enum_values: + return enum_values[0] + if is_dataclass(annotation_type): + return self._dataclass_value(cast(type, annotation_type), schema) + if annotation_type is str: + return self._string_value(name, schema) + if annotation_type is int: + return 1 + if annotation_type is float: + return 1.5 + if annotation_type is bool: + return True + return _MISSING + + def _dataclass_value(self, model_type: type, schema: SwaggerSchema | None) -> object: + type_hints = get_type_hints(model_type) + kwargs: dict[str, object] = {} + schema_properties = schema.properties if schema is not None and schema.is_object else {} + schema_required = schema.required if schema is not None and schema.is_object else frozenset() + for field in fields(model_type): + field_schema = _schema_for_dataclass_field(field.name, schema_properties) + field_type = type_hints.get(field.name) + should_fill = ( + field.default is DATACLASS_MISSING + and field.default_factory is DATACLASS_MISSING ) - if "OrderDeliveryProperties" in annotation: - from avito.orders.models import OrderDeliveryProperties - - return OrderDeliveryProperties(dimensions=[10, 10, 10], weight=100) - if "RealAddress" in annotation: - from avito.orders.models import RealAddress - - return RealAddress(address_type="terminal", terminal_number="terminal-1") - if "CustomAreaScheduleEntry" in annotation: - from avito.orders.models import CustomAreaScheduleEntry, DeliveryDateInterval - - return [ - CustomAreaScheduleEntry( - provider_area_numbers=["area-1"], - services=["delivery"], - custom_schedule=[ - DeliveryDateInterval(date="2026-05-01", intervals=["09:00-18:00"]) - ], - ) - ] - return self._value_for_name(field_name) + if schema_properties: + serialized_names = _name_aliases(field.name) + should_fill = should_fill or any(name in schema_required for name in serialized_names) + should_fill = should_fill or field_schema is not None and not _allows_none(field_type) + if not should_fill: + continue + value = self._value_for_type(field.name, field_type, field_schema) + if value is _MISSING: + value = self._value_for_name(field.name) + kwargs[field.name] = value + return model_type(**kwargs) + + def _string_value(self, name: str, schema: SwaggerSchema | None) -> str: + if schema is not None and schema.enum: + enum_value = schema.enum[0] + if isinstance(enum_value, str): + return enum_value + value = self._value_for_name(name) + return value if isinstance(value, str) else str(value) def _should_supply_optional_argument( self, @@ -502,101 +588,30 @@ def _value_for_name(self, name: str) -> object: return [RealtyInterval(date="2026-05-01", available=True)] if name == "blocked_dates": return ["2026-05-01"] + if name == "data": + return ["XTA210990Y2766384"] + if name == "autoload_enabled": + return True + if name == "feeds_data": + return "https://example.test/feed.xml" + if name == "report_email": + return "autoload@example.test" + if name == "schedule": + return 100 + if name == "upload_url": + return "https://example.test/feed.xml" if name == "date_start": return "2026-05-01" if name == "date_end": return "2026-05-02" - if name in _BODY_VALUES: - return _BODY_VALUES[name] - if name in _SDK_CONSTANTS: - return _SDK_CONSTANTS[name] + dateish_value = _dateish_value(name) + if dateish_value is not _MISSING: + return dateish_value + value = _known_value(*_name_aliases(name)) + if value is not _MISSING: + return value return f"{name}-value" - def _sorting_center_upload(self) -> object: - from avito.orders.models import SortingCenterUpload - - return SortingCenterUpload( - delivery_provider_id="provider-1", - name="СЦ", - address=self._delivery_address(), - phones=["+70000000000"], - itinerary="Вход", - photos=["photo-1"], - schedule=self._weekly_schedule(), - restriction=self._delivery_restriction(), - direction_tag="tag-1", - ) - - def _terminal_upload(self) -> object: - from avito.orders.models import TerminalUpload - - return TerminalUpload( - delivery_provider_id="provider-1", - name="ПВЗ", - address=self._delivery_address(), - phones=["+70000000000"], - itinerary="Вход", - photos=["photo-1"], - direction_tag="tag-1", - services=["pickup"], - schedule=self._weekly_schedule(), - restriction=self._delivery_restriction(), - ) - - def _delivery_address(self) -> DeliveryAddress: - from avito.orders.models import DeliveryAddress - - return DeliveryAddress( - country="RU", - region="Москва", - locality="Москва", - fias="fias-1", - zip_code="101000", - lat=55.75, - lng=37.62, - ) - - def _weekly_schedule(self) -> WeeklySchedule: - from avito.orders.models import WeeklySchedule - - hours = ["09:00-18:00"] - return WeeklySchedule( - mon=hours, - tue=hours, - wed=hours, - thu=hours, - fri=hours, - sat=hours, - sun=hours, - ) - - def _delivery_restriction(self) -> DeliveryRestriction: - from avito.orders.models import DeliveryRestriction - - return DeliveryRestriction( - max_weight=1000, - max_dimensions=[10, 10, 10], - max_declared_cost=10000, - ) - - def _sandbox_participant(self, participant_type: str) -> object: - from avito.orders.models import ( - SandboxAnnouncementDelivery, - SandboxAnnouncementParticipant, - SandboxDeliveryPoint, - ) - - return SandboxAnnouncementParticipant( - type=participant_type, - phones=["+70000000000"], - email=f"{participant_type}@example.test", - name=participant_type, - delivery=SandboxAnnouncementDelivery( - type="terminal", - terminal=SandboxDeliveryPoint(provider="pochta", point_id="point-1"), - ), - ) - def _match_route(self, request: RecordedRequest) -> SwaggerRoute: for route in self._swagger_routes.values(): if route.operation.method != request.method: @@ -634,7 +649,9 @@ def _validate_request(self, operation: SwaggerOperation, request: RecordedReques raise AssertionError(f"{operation.key}: requestBody обязателен.") content_type = request.headers.get("content-type", "") if request.content and operation.request_body.content_types: - if not any(expected in content_type for expected in operation.request_body.content_types): + if not any( + expected in content_type for expected in operation.request_body.content_types + ): raise AssertionError( f"{operation.key}: content-type `{content_type}` не описан в Swagger." ) @@ -663,7 +680,9 @@ def _validate_declared_status(self, operation: SwaggerOperation, status_code: in ) def _path_matches(self, template: str, path: str) -> bool: - return self._path_pattern(template).fullmatch(self._normalize_swagger_path(path)) is not None + return ( + self._path_pattern(template).fullmatch(self._normalize_swagger_path(path)) is not None + ) def _extract_path_values(self, template: str, path: str) -> Mapping[str, str]: match = self._path_pattern(template).fullmatch(self._normalize_swagger_path(path)) @@ -699,7 +718,9 @@ def error_payload(status_code: int) -> JsonValue: def success_payload(operation: SwaggerOperation) -> JsonValue: """Build deterministic success payload for one operation.""" - if operation.spec in {"Авторизация.json", "Автотека.json"} and operation.path.startswith("/token"): + if operation.spec in {"Авторизация.json", "Автотека.json"} and operation.path.startswith( + "/token" + ): return { "access_token": "access-token", "token_type": "Bearer", @@ -713,7 +734,10 @@ def success_payload(operation: SwaggerOperation) -> JsonValue: return {"orderId": "order-1", "status": "active", "items": [], "errors": []} if operation.key == "Настройкаценыцелевогодействия.json GET /cpxpromo/1/getBids/{itemId}": return {"actionTypeID": 1, "selectedType": "manual", "manual": {}, "auto": {}} - if operation.key == "Настройкаценыцелевогодействия.json POST /cpxpromo/1/getPromotionsByItemIds": + if ( + operation.key + == "Настройкаценыцелевогодействия.json POST /cpxpromo/1/getPromotionsByItemIds" + ): return {"items": []} return {} @@ -734,4 +758,96 @@ def _annotation_name(parameter: inspect.Parameter | None) -> str: return str(annotation) +def _callable_type_hints(callable_object: Callable[..., object]) -> Mapping[str, object]: + try: + return get_type_hints(callable_object) + except (NameError, TypeError): + return {} + + +def _known_value(*names: str) -> object: + for name in names: + if name in _BODY_VALUES: + return _BODY_VALUES[name] + if name in _SDK_CONSTANTS: + return _SDK_CONSTANTS[name] + return _MISSING + + +def _name_aliases(name: str) -> tuple[str, ...]: + aliases = _NAME_ALIASES.get(name, ()) + return (*swagger_field_aliases(name), *aliases) + + +def _schema_for_dataclass_field( + field_name: str, + schema_properties: Mapping[str, SwaggerSchema], +) -> SwaggerSchema | None: + for name in _name_aliases(field_name): + if name in schema_properties: + return schema_properties[name] + return None + + +def _unwrap_optional(annotation_type: object) -> object: + origin = get_origin(annotation_type) + if origin not in {UnionType, Union}: + return annotation_type + args = tuple(item for item in get_args(annotation_type) if item is not type(None)) + if len(args) == 1: + return args[0] + return annotation_type + + +def _allows_none(annotation_type: object | None) -> bool: + if annotation_type is None: + return True + origin = get_origin(annotation_type) + if origin not in {UnionType, Union}: + return False + return any(item is type(None) for item in get_args(annotation_type)) + + +def _first_type_arg(annotation_type: object) -> object | None: + args = get_args(annotation_type) + return args[0] if args else None + + +def _is_date_input_type(annotation_type: object) -> bool: + origin = get_origin(annotation_type) + if origin not in {UnionType, Union}: + return False + args = set(get_args(annotation_type)) + return date in args and datetime in args and str in args + + +def _dateish_value(name: str) -> object: + normalized = name.lower() + if normalized in {"date", "created_at", "updated_at"}: + return "2026-05-01T00:00:00+00:00" + if "date" in normalized or "time" in normalized or normalized.endswith("_at"): + return "2026-05-01T00:00:00+00:00" + return _MISSING + + +def _singular_name(name: str) -> str: + if name.endswith("ies"): + return f"{name[:-3]}y" + if name.endswith("s"): + return name[:-1] + return name + + +def _is_nested_body_path(path: SwaggerBodyPath) -> bool: + return len(path.segments) > 1 or any(segment.array for segment in path.segments) + + +def _coerce_schema_value(value: object, path: SwaggerBodyPath) -> object: + if path.leaf_schema.kind == "string" and isinstance(value, int | float) and not isinstance( + value, bool + ): + return str(value) + return value + + __all__ = ("SwaggerFakeTransport", "SwaggerRoute", "error_payload", "success_payload") diff --git a/avito/testing/swagger_schema.py b/avito/testing/swagger_schema.py new file mode 100644 index 0000000..d127fa9 --- /dev/null +++ b/avito/testing/swagger_schema.py @@ -0,0 +1,131 @@ +"""Strict Swagger JSON schema helpers for SDK contract tests.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence + +from avito.core.swagger_registry import SwaggerSchema + +JsonValue = Mapping[str, object] | list[object] | str | int | float | bool | None + + +def generate_schema_value(schema: SwaggerSchema) -> JsonValue: + """Build deterministic JSON value that covers all fields in a Swagger schema.""" + + if schema.enum: + value = schema.enum[0] + if _value_matches_kind(value, schema.kind): + return _json_value(value) + if schema.kind == "object": + return { + name: generate_schema_value(property_schema) + for name, property_schema in schema.properties.items() + } + if schema.kind == "array": + if schema.items is None: + raise AssertionError("Swagger array schema не содержит items.") + return [generate_schema_value(schema.items)] + if schema.kind == "union": + if not schema.variants: + raise AssertionError("Swagger union schema не содержит variants.") + return generate_schema_value(schema.variants[0]) + if schema.kind == "string": + return "value" + if schema.kind == "integer": + return 1 + if schema.kind == "number": + return 1.5 + if schema.kind == "boolean": + return True + if schema.kind == "null": + return None + raise AssertionError(f"Неподдерживаемый Swagger schema kind: {schema.kind}") + + +def validate_schema_value(value: object, schema: SwaggerSchema, *, path: str) -> None: + """Validate JSON value keys and types against a normalized Swagger schema.""" + + if value is None and schema.nullable: + return + if schema.kind == "union": + variant_errors: list[str] = [] + for variant in schema.variants: + try: + validate_schema_value(value, variant, path=path) + except AssertionError as exc: + variant_errors.append(str(exc)) + else: + return + raise AssertionError( + f"{path}: значение не соответствует ни одному варианту: {variant_errors}" + ) + if schema.kind == "object": + if not isinstance(value, Mapping): + raise AssertionError(f"{path}: ожидался object, получен {_json_type(value)}.") + missing = sorted(name for name in schema.required if name not in value) + if missing: + raise AssertionError(f"{path}: отсутствуют обязательные поля {missing}.") + extra = sorted(str(name) for name in value if name not in schema.properties) + if extra and schema.properties: + raise AssertionError(f"{path}: лишние поля {extra}.") + for name, item in value.items(): + property_schema = schema.properties.get(str(name)) + if property_schema is None: + continue + validate_schema_value(item, property_schema, path=f"{path}.{name}") + return + if schema.kind == "array": + if not isinstance(value, Sequence) or isinstance(value, str | bytes | bytearray): + raise AssertionError(f"{path}: ожидался array, получен {_json_type(value)}.") + if schema.items is None: + raise AssertionError(f"{path}: Swagger array schema не содержит items.") + for index, item in enumerate(value): + validate_schema_value(item, schema.items, path=f"{path}[{index}]") + return + if not _value_matches_kind(value, schema.kind): + raise AssertionError(f"{path}: ожидался {schema.kind}, получен {_json_type(value)}.") + + +def _value_matches_kind(value: object, kind: str) -> bool: + if kind == "string": + return isinstance(value, str) + if kind == "integer": + return isinstance(value, int) and not isinstance(value, bool) + if kind == "number": + return isinstance(value, int | float) and not isinstance(value, bool) + if kind == "boolean": + return isinstance(value, bool) + if kind == "null": + return value is None + if kind == "object": + return isinstance(value, Mapping) + if kind == "array": + return isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray) + return False + + +def _json_type(value: object) -> str: + if value is None: + return "null" + if isinstance(value, bool): + return "boolean" + if isinstance(value, int): + return "integer" + if isinstance(value, float): + return "number" + if isinstance(value, str): + return "string" + if isinstance(value, Mapping): + return "object" + if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): + return "array" + return type(value).__name__ + + +def _json_value(value: object) -> JsonValue: + if value is None or isinstance(value, str | int | float | bool): + return value + raise AssertionError(f"Swagger enum value не является JSON scalar: {value!r}") + + +__all__ = ("generate_schema_value", "validate_schema_value") diff --git a/docs/site/explanations/architecture.md b/docs/site/explanations/architecture.md index 659418a..859f4aa 100644 --- a/docs/site/explanations/architecture.md +++ b/docs/site/explanations/architecture.md @@ -1,20 +1,28 @@ # Архитектура SDK -SDK построен вокруг одного публичного фасада `AvitoClient`. Он создаёт доменные объекты, а доменные объекты делегируют HTTP-операции section client-ам. Transport отвечает за `httpx`, retry, token injection и маппинг ошибок. Mappers преобразуют JSON в публичные dataclass-модели. +SDK построен вокруг одного публичного фасада `AvitoClient`. Он создаёт доменные +объекты, а доменные объекты исполняют явно описанные `OperationSpec` через общий +`OperationExecutor`. `Transport` отвечает за `httpx`, retry, token injection и +маппинг ошибок. Публичные dataclass-модели сами преобразуют JSON через +`from_payload()`, а request/query dataclass-и сериализуются через `to_payload()` +и `to_params()`. ```mermaid flowchart LR user[Пользовательский код] --> facade[AvitoClient] facade --> domain[Доменный объект] - domain --> section[SectionClient] - section --> transport[Transport] + domain --> spec[OperationSpec] + domain --> executor[OperationExecutor] + spec --> executor + executor --> transport[Transport] transport --> auth[AuthProvider] transport --> api[Avito API] - section --> mapper[Mapper] - mapper --> model[SDK model] + executor --> model[SDK model from_payload] ``` -Такое разделение удерживает публичный API простым: пользовательский код работает с доменными объектами и typed-моделями, но не управляет заголовками, refresh token-flow, retry-циклами или JSON-маппингом вручную. +Такое разделение удерживает публичный API простым: пользовательский код работает +с доменными объектами и typed-моделями, но не управляет заголовками, refresh +token-flow, retry-циклами или JSON-маппингом вручную. ## Границы слоёв @@ -22,9 +30,10 @@ flowchart LR |---|---| | `AvitoClient` | Единая точка входа, context manager, фабрики доменных объектов | | Domain object | Публичные методы конкретного сценария, например `account().get_self()` | -| Section client | HTTP path, method, payload и выбор mapper-а для одного API-раздела | +| `OperationSpec` | HTTP method/path, retry mode, request/query model class и response model class | +| `OperationExecutor` | Path rendering, сериализация query/body, вызов transport и передача response payload в модель | | `Transport` | `httpx.Client`, retry, timeouts, auth header, error mapping | | `AuthProvider` | Получение, кэширование и инвалидирование токенов | -| Mapper | Преобразование JSON-ответа в публичную SDK-модель | +| Model | `from_payload()`, `to_payload()`, `to_params()`, enum-ы и публичная сериализация | Публичные методы не возвращают raw `dict` и не принимают transport-layer request DTO. Если операция требует сложный payload, доменный метод раскрывает понятные keyword-only параметры или публичную модель, закреплённую в reference. diff --git a/docs/site/explanations/domain-architecture-v2.md b/docs/site/explanations/domain-architecture-v2.md new file mode 100644 index 0000000..253d742 --- /dev/null +++ b/docs/site/explanations/domain-architecture-v2.md @@ -0,0 +1,236 @@ +# Целевая структура доменов + +Эта страница фиксирует архитектуру доменных пакетов SDK. Все API-домены SDK +используют v2 layout: публичные методы находятся в `domain.py`, HTTP-контракты +в `operations.py` или `operations/`, а модели, enum-ы и payload mapping +принадлежат `models.py` или `models/`. + +## Основной принцип + +Данные API должны описываться в одном месте: в dataclass-моделях домена. + +Модель отвечает за: + +- десериализацию API JSON в SDK-модель через `from_payload()`; +- нормализацию типов: даты, числа, enum, nullable-поля, альтернативные имена ключей; +- публичную сериализацию через `to_dict()` и `model_dump()`; +- сериализацию request/query моделей через `to_payload()` и `to_params()`; +- enum-ы, относящиеся к этой модели или группе моделей. + +Отдельные mapper-функции не являются способом преобразования JSON для +API-доменов. Domain-level `client.py`, `mappers.py` и standalone `enums.py` +в API-доменах запрещены. + +## Структура простого домена + +```text +avito/ratings/ + __init__.py + domain.py + operations.py + models.py +``` + +Назначение файлов: + +| Файл | Ответственность | +|---|---| +| `domain.py` | Публичные `DomainObject`-классы, reference-ready docstring-и, `@swagger_operation(...)`, бизнес-валидация и сбор публичных request-моделей | +| `operations.py` | Внутренние `OperationSpec`: HTTP method, path, operation context, retry policy и response/request model classes | +| `models.py` | Dataclass-модели, enum-ы, `from_payload()`, `to_payload()`, `to_params()` и нормализация | + +В простом домене не используются отдельные `client.py`, `mappers.py` и +`enums.py`. + +## Структура большого домена + +Если домен содержит много независимых подсекций, модели и операции можно дробить по пакетам, сохраняя тот же принцип владения данными. + +```text +avito/orders/ + __init__.py + domain.py + operations/ + __init__.py + orders.py + labels.py + delivery.py + stock.py + models/ + __init__.py + orders.py + labels.py + delivery.py + stock.py +``` + +Enum должен лежать рядом с моделями, которые его используют: + +```python +from dataclasses import dataclass +from enum import StrEnum +from avito.core.models import ApiModel +from avito.core.payload import JsonReader + + +class OrderStatus(StrEnum): + CREATED = "created" + PAID = "paid" + DELIVERED = "delivered" + CANCELLED = "cancelled" + UNKNOWN = "unknown" + + +@dataclass(slots=True, frozen=True) +class OrderInfo(ApiModel): + order_id: str + status: OrderStatus + + @classmethod + def from_payload(cls, payload: object) -> "OrderInfo": + reader = JsonReader(payload) + return cls( + order_id=reader.required_str("id", "order_id", "orderId"), + status=reader.enum(OrderStatus, "status", unknown=OrderStatus.UNKNOWN), + ) +``` + +## Базовые модели + +`core` предоставляет только инфраструктурные базовые классы. Они не знают о конкретных доменах Avito. + +```python +from avito.core.serialization import SerializableModel + + +class ApiModel(SerializableModel): + """Base class for public typed API response models.""" + + +class RequestModel: + def to_payload(self) -> dict[str, object]: + return serialize_dataclass_payload(self) + + def to_params(self) -> dict[str, object]: + return serialize_dataclass_payload(self) +``` + +Request-модель может использовать metadata для API-имён полей: + +```python +from dataclasses import dataclass + +from avito.core.fields import api_field +from avito.core.models import RequestModel + + +@dataclass(slots=True, frozen=True) +class CreateReviewAnswerRequest(RequestModel): + review_id: int = api_field("reviewId") + text: str = api_field("text") +``` + +## Описание операций + +HTTP-контракт операции описывается отдельно от моделей, но не содержит JSON-маппинга. + +```python +from avito.core.operations import OperationSpec +from avito.ratings.models import CreateReviewAnswerRequest, ReviewAnswerInfo + + +CREATE_REVIEW_ANSWER = OperationSpec( + name="ratings.answers.create", + method="POST", + path="/ratings/v1/answers", + request_model=CreateReviewAnswerRequest, + response_model=ReviewAnswerInfo, + retry_mode="enabled", +) +``` + +`OperationExecutor` выполняет запрос через `Transport`, а затем вызывает `response_model.from_payload(payload)`. + +## Публичный доменный метод + +Публичный метод остаётся явным: сигнатура, docstring и Swagger binding должны быть видны в коде и документации. + +```python +from dataclasses import dataclass +from typing import cast + +from avito.core.domain import DomainObject +from avito.core.swagger import swagger_operation +from avito.ratings import operations +from avito.ratings.models import CreateReviewAnswerRequest, ReviewAnswerInfo + + +@dataclass(slots=True, frozen=True) +class ReviewAnswer(DomainObject): + __swagger_domain__ = "ratings" + __sdk_factory__ = "review_answer" + __sdk_factory_args__ = {"answer_id": "path.answer_id"} + + answer_id: int | str | None = None + + @swagger_operation( + "POST", + "/ratings/v1/answers", + spec="Рейтингииотзывы.json", + operation_id="createReviewAnswerV1", + method_args={"review_id": "body.review_id", "text": "body.message"}, + ) + def create( + self, + *, + review_id: int, + text: str, + idempotency_key: str | None = None, + ) -> ReviewAnswerInfo: + """Создаёт ответ на отзыв. + + Параметр `idempotency_key` задаёт ключ идемпотентности для безопасного повтора write-операции. + + Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + """ + + request = CreateReviewAnswerRequest(review_id=review_id, text=text) + return cast(ReviewAnswerInfo, self._execute( + operations.CREATE_REVIEW_ANSWER, + request=request, + idempotency_key=idempotency_key, + )) +``` + +Публичные методы нельзя генерировать через `setattr`, `globals()` или другой runtime-patching. Новый слой операций сокращает внутреннее дублирование, но публичный SDK-контракт остаётся явным. + +## Что выносится в core + +`core` может содержать: + +- `ApiModel`, `RequestModel` и shared serialization helpers; +- `JsonReader` для безопасного чтения JSON без знания доменных моделей; +- `api_field()` и другие helpers для dataclass metadata; +- `OperationSpec`, `OperationExecutor`, retry mode и path rendering; +- общие стратегии пагинации: offset, page, cursor; +- универсальную primitive-валидацию; +- transport, retries, exceptions, swagger discovery и swagger lint. + +`core` не должен содержать: + +- enum-ы конкретных доменов; +- request/response модели конкретных API-разделов; +- fallback-ключи конкретных API payload-ов; +- бизнес-валидацию вроде требования `item_id`, `order_id` или `review_id`; +- знание о том, какие поля входят в конкретный Avito endpoint. + +## Инварианты + +- Каждый публичный API-метод остаётся привязан к ровно одной Swagger operation через `@swagger_operation(...)`. +- Каждый публичный API-метод исполняет ровно один `OperationSpec`. +- Один `OperationSpec` описывает один transport-вызов, а его HTTP method/path + совпадают с привязанной Swagger operation. +- JSON-маппинг response payload находится в `ResponseModel.from_payload()`. +- JSON-маппинг request/query payload находится в request dataclass. +- Enum-ы находятся в `models.py` или в подпакете `models/`, рядом с моделями. +- `client.py`, `mappers.py` и standalone `enums.py` отсутствуют в API-доменах. diff --git a/docs/site/explanations/error-model.md b/docs/site/explanations/error-model.md index 27fb53f..a686fb5 100644 --- a/docs/site/explanations/error-model.md +++ b/docs/site/explanations/error-model.md @@ -8,6 +8,7 @@ SDK переводит HTTP-статусы, transport failures и ошибки |---|---| | Некорректный пользовательский ввод | `ValidationError` | | Не хватает настроек клиента | `ConfigurationError` | +| Вызов после `AvitoClient.close()` | `ClientClosedError` | | `401` | `AuthenticationError` | | `403` | `AuthorizationError` | | `409` | `ConflictError` | diff --git a/docs/site/explanations/index.md b/docs/site/explanations/index.md index 8c8bacd..9b4c814 100644 --- a/docs/site/explanations/index.md +++ b/docs/site/explanations/index.md @@ -4,7 +4,8 @@ Explanations описывают причины архитектурных реш | Статья | Что объясняет | |---|---| -| [Архитектура SDK](architecture.md) | Как `AvitoClient`, домены, section clients, transport, auth и mappers разделяют ответственность | +| [Архитектура SDK](architecture.md) | Как `AvitoClient`, домены, `OperationSpec`, executor, transport, auth и модели разделяют ответственность | +| [Целевая структура доменов](domain-architecture-v2.md) | Как API-домены используют dataclass-модели для сериализации, десериализации, нормализации и enum-ов | | [OAuth и токены](auth-flow.md) | Почему token-flow скрыт за `AuthProvider` | | [Transport и retry](transport-and-retries.md) | Почему retry живёт в transport-слое и как учитываются 429/5xx | | [Модель ошибок](error-model.md) | Как HTTP-коды превращаются в typed exceptions | diff --git a/docs/site/explanations/swagger-binding-subsystem.md b/docs/site/explanations/swagger-binding-subsystem.md index 7eb556f..7101006 100644 --- a/docs/site/explanations/swagger-binding-subsystem.md +++ b/docs/site/explanations/swagger-binding-subsystem.md @@ -15,7 +15,7 @@ Swagger/OpenAPI-файлы в `docs/avito/api/*.json` остаются един | Binding decorator | `avito/core/swagger.py` | Записывает metadata на публичный SDK-метод | | Swagger registry | `avito/core/swagger_registry.py` | Загружает `docs/avito/api/*.json`, нормализует операции и проверяет базовую валидность specs | | Binding discovery | `avito/core/swagger_discovery.py` | Находит decorated public domain methods без создания `AvitoClient` и без HTTP | -| Linter | `avito/core/swagger_linter.py`, `scripts/lint_swagger_bindings.py` | Проверяет, что binding-и полные, уникальные и соответствуют Swagger | +| Linter | `avito/core/swagger_linter.py`, `scripts/lint_swagger_bindings.py` | Проверяет, что binding-и полные, уникальные, соответствуют Swagger и исполняются через matching `OperationSpec` | | Report | `avito/core/swagger_report.py` | Формирует JSON report для docs/reference и coverage | | Factory map | `avito/core/swagger_factory_map.py` | Даёт вспомогательную, неканоническую карту `AvitoClient factory -> domain class -> spec candidates` | | Contract runner | `avito/testing/swagger_fake_transport.py` | Строит SDK-вызовы по binding metadata и валидирует фактический request/response через Swagger | @@ -56,7 +56,10 @@ __sdk_factory_args__: Mapping[str, str] 2. Значения из class-level metadata. 3. Auto-resolve через registry, только если `method + normalized_path` совпадает ровно с одной Swagger operation во всём corpus. -Decorator записывает metadata в `func.__swagger_binding__`. Он не меняет поведение метода и не читает Swagger-файлы на import time. Повторная разметка того же SDK method запрещена, а legacy metadata `__swagger_bindings__` считается ошибкой совместимости. +Decorator записывает metadata в `func.__swagger_binding__`. Он не меняет +поведение метода и не читает Swagger-файлы на import time. Повторная разметка +того же SDK method запрещена, а устаревшая metadata `__swagger_bindings__` +считается ошибкой. ## Operation identity @@ -88,11 +91,15 @@ spec + method + normalized_path | `header.` | header parameter Swagger operation | | `body` | весь request body | | `body.` | поле request body | +| `body.[].` | поле элемента массива в request body | +| `body..` | вложенное поле объекта в request body | | `constant.` | контролируемая тестовая константа | Expressions не являются Python-кодом. Произвольные callables, dotted paths вне whitelist и transport/request DTO запрещены. -Текущая реализация валидирует `path.*`, `query.*`, `header.*`, наличие `requestBody` для `body`, field-level `body.` против top-level request body schema properties и наличие `constant.*` в test constants registry. Для Swagger properties с camelCase/Pascal acronym naming registry также хранит SDK-style snake_case aliases, чтобы binding мог ссылаться на публичные Python-имена без потери schema-aware проверки. +Текущая реализация валидирует `path.*`, `query.*`, `header.*`, наличие `requestBody` для `body`, body paths против request body `SwaggerSchema` и наличие `constant.*` в test constants registry. Body path grammar намеренно ограничена: `body.field`, `body.array[]`, `body.array[].field`, `body.object.field`. Для Swagger properties с camelCase/Pascal acronym naming registry также хранит SDK-style snake_case aliases, чтобы binding мог ссылаться на публичные Python-имена без потери schema-aware проверки. + +Registry дополнительно строит normalized JSON schema tree для `requestBody` и всех Swagger responses: object properties, required fields, arrays, scalar JSON types, nullable, enum, `$ref`, `allOf`, `oneOf` и `anyOf`. Неразобранная JSON schema является contract failure, а не пропуском покрытия. ## Discovery @@ -103,7 +110,8 @@ Discovery импортирует пакет `avito`, но не создаёт `A - private methods; - internal helpers; - summary/helper methods на `AvitoClient`, если они не соответствуют одной конкретной upstream operation; -- section clients как canonical target, кроме явно задокументированных legacy/non-domain exceptions. +- low-level implementation methods без публичного domain binding, кроме + явно задокументированных non-domain exceptions. ## Linter modes @@ -118,7 +126,14 @@ make swagger-lint make swagger-coverage ``` -Non-strict mode валидирует specs и уже найденные bindings. Strict mode дополнительно требует, чтобы каждая Swagger operation имела ровно один binding. `make swagger-lint` сначала скачивает свежие Swagger/OpenAPI files через `make swagger-update`, затем запускает strict validation. `make swagger-coverage` дополнительно запускает полный Swagger contract suite и входит в `make check`. +Non-strict mode валидирует specs и уже найденные bindings. Strict mode +дополнительно требует, чтобы каждая Swagger operation имела ровно один binding, +каждый API-domain binding исполнялся через ровно один `OperationSpec`, method/path +этого `OperationSpec` совпадали со Swagger operation, а API-domain +`OperationSpec` без публичного binding отсутствовали. `make swagger-lint` +сначала скачивает свежие Swagger/OpenAPI files через `make swagger-update`, +затем запускает strict validation. `make swagger-coverage` дополнительно +запускает полный Swagger contract suite и входит в `make check`. JSON report используется как стабильный machine-readable API для generated reference и coverage: @@ -170,8 +185,10 @@ One SDK method must not have multiple Swagger bindings. When a user-facing scena 3. Call the public SDK method with `method_args`. 4. Match the actual HTTP request against Swagger method/path. 5. Validate required path/query/header parameters and request body/content type. -6. Return declared Swagger response statuses only. -7. Let normal SDK mapping and exception mapping run. +6. Validate actual JSON request bodies against Swagger keys and JSON types. +7. Generate Swagger-shaped success and error response bodies. +8. Return declared Swagger response statuses only. +9. Let normal SDK mapping and exception mapping run. Contract tests must stay network-free. They are not a replacement for domain tests, but they catch binding drift: a method can be present in docs yet still fail contract invocation if factory args, method args, path, body or status handling are wrong. @@ -179,21 +196,29 @@ The contract suite is exhaustive over the Swagger binding map: - one request-contract case per discovered binding; - one error-contract case per numeric Swagger error response; +- one schema-contract case per JSON request body; +- one schema-contract case per JSON success response model; +- one schema-contract case per JSON error response payload; - deprecated operation bindings are included in the request set and additionally checked for runtime `DeprecationWarning`. -`SwaggerFakeTransport` provides deterministic generated SDK arguments and success payloads. The default success payload is the minimal JSON object accepted by most SDK mappers; operations whose mappers require a domain-specific response shape are listed in the controlled payload registry in `avito/testing/swagger_fake_transport.py`. Missing generated arguments or unsupported payload shapes are contract failures, not allowlisted gaps. +`SwaggerFakeTransport` provides deterministic generated SDK arguments. Schema +contract helpers in `avito/testing/swagger_schema.py` generate payloads from +Swagger schemas and compare actual SDK request payloads by field key and JSON +type. Missing generated arguments, missing `OperationSpec` models, unsupported +schema shapes and request/response/error payload mismatches are contract +failures, not allowlisted gaps. ## API method change checklist When adding or changing a public API method that corresponds to Avito API: 1. Confirm the upstream operation in `docs/avito/api/*.json`. -2. Add or update the domain method, section client call, mapper and public models. +2. Add or update the domain method, `OperationSpec`, request/query models, response models, and model-owned payload parsing. 3. Add `@swagger_operation(...)` on the public domain method without schemas/statuses/content types in the decorator. 4. Add or update class-level metadata if the domain class is new. 5. Document the public method through docstring so generated reference explains arguments, return model, pagination/dry-run/idempotency behavior and common exceptions. 6. Add focused domain tests with `FakeTransport`. -7. Add or adjust mapper/model tests when response or serialization changes. +7. Add or adjust model tests when response parsing or serialization changes. 8. Ensure the binding is exercised by strict `make swagger-lint` and the exhaustive `SwaggerFakeTransport` contract tests. 9. Update user-facing docs when the method creates a new workflow, changes behavior, or introduces a non-obvious contract. diff --git a/docs/site/explanations/testing-strategy.md b/docs/site/explanations/testing-strategy.md index 99f761c..edcb6ba 100644 --- a/docs/site/explanations/testing-strategy.md +++ b/docs/site/explanations/testing-strategy.md @@ -6,7 +6,7 @@ SDK тестируется через публичные контракты: д | Уровень | Что проверяет | |---|---| -| Unit | Мапперы, сериализация моделей, validation | +| Unit | `from_payload()`, сериализация моделей, validation | | Contract | Публичная поверхность, все Swagger bindings, все numeric Swagger error responses, deprecated warnings | | Domain | Доменные методы поверх `FakeTransport` | | Docs | README/tutorials/how-to snippets через `mktestdocs` | @@ -24,6 +24,9 @@ Docs-harness использует тот же подход: `AvitoClient.from_en ## Почему не мокать domain methods -Если тест подменяет `account().get_self()` напрямую, он проверяет только consumer-код. Если тест строит `AvitoClient` поверх fake transport, он дополнительно проверяет HTTP path, payload, mapper и публичную модель. Поэтому fake transport ближе к реальному интеграционному контракту. +Если тест подменяет `account().get_self()` напрямую, он проверяет только +consumer-код. Если тест строит `AvitoClient` поверх fake transport, он +дополнительно проверяет HTTP path, payload, `from_payload()` и публичную модель. +Поэтому fake transport ближе к реальному интеграционному контракту. Практический reference по testing API смотрите в [reference по тестированию](../reference/testing.md). diff --git a/docs/site/explanations/transport-and-retries.md b/docs/site/explanations/transport-and-retries.md index 05c409c..7c52d33 100644 --- a/docs/site/explanations/transport-and-retries.md +++ b/docs/site/explanations/transport-and-retries.md @@ -1,15 +1,17 @@ # Transport и retry -`Transport` — единственный слой, который работает с `httpx`, таймаутами, retry и mapping HTTP-ошибок. Домены и section clients не повторяют эту логику, иначе публичное поведение разных разделов начало бы расходиться. +`Transport` — единственный слой, который работает с `httpx`, таймаутами, retry +и mapping HTTP-ошибок. Домены и `OperationExecutor` не повторяют эту логику, +иначе публичное поведение разных разделов начало бы расходиться. ```mermaid flowchart TD - call[SectionClient request] --> auth{Нужен токен?} + call[OperationExecutor request] --> auth{Нужен токен?} auth -- да --> token[AuthProvider] auth -- нет --> send[httpx request] token --> send send --> status{Ответ} - status -- 2xx --> map[JSON или binary mapper] + status -- 2xx --> map[JSON, empty или binary response] status -- 401 --> refresh[Инвалидация токена] refresh --> retry401{Можно повторить?} retry401 -- да --> token @@ -23,12 +25,34 @@ flowchart TD ## Что повторяется -Retry применяется только там, где операция помечена как безопасная для повтора. Read/list/probe операции обычно допускают retry. Write-операции получают retry только при явной идемпотентности, например через `idempotency_key`, или когда конкретный section client помечает операцию как безопасную. +Retry применяется только там, где операция помечена как безопасная для повтора. +Read/list/probe операции обычно допускают retry. Write-операции получают retry +только при явной идемпотентности, например через `idempotency_key`, или когда +конкретный `OperationSpec` помечает операцию как безопасную. + +`POST` и `PATCH` без `idempotency_key` не повторяются даже при retryable +transport-политике. `DELETE` тоже не повторяется без `idempotency_key`, если +операция явно не помечена как безопасная для retry через `OperationSpec`. +Это правило имеет приоритет над `RetryPolicy.retryable_methods`, поэтому +глобальная политика не может случайно включить повтор небезопасного удаления. `429` учитывает `Retry-After`, если upstream его вернул. Если `Retry-After` отсутствует, transport использует обычный exponential backoff с jitter. Для `5xx` используется retry-политика transport-слоя. Ошибки маппинга не повторяются: если JSON уже получен, но не соответствует контракту модели, это `ResponseMappingError`, а не сетевой сбой. Чтобы снижать вероятность `429` до ответа upstream, можно включить локальный token bucket через `AVITO_RATE_LIMIT_ENABLED=true`. Лимитер применяется в transport-слое перед отправкой запроса и дополнительно учитывает `X-RateLimit-Remaining: 0`, когда API возвращает этот заголовок. +## Логирование transport + +Каждая HTTP-попытка пишет debug-событие в logger `avito.transport` с полями +`operation`, `endpoint`, `method`, `attempt`, `status`, `latency_ms` и +`request_id`. Для сетевых ошибок до ответа upstream `status` и `request_id` +остаются `None`, но попытка всё равно логируется. Retry-события используют те +же `operation`, `endpoint`, `method`, `attempt` и `status`, а также добавляют +`delay_ms` и `reason`. + +Transport не логирует body, query payload, OAuth headers, idempotency keys и +другие секретные значения. Для подробностей об ошибке используйте поля +типизированного исключения. + ## Почему retry не в доменах Доменный объект должен описывать публичный сценарий: `order_label().create()` или `ad_stats().get_item_stats()`. Если retry появится на этом уровне, одинаковые HTTP-коды начнут вести себя по-разному в разных пакетах. Поэтому retry централизован и проверяется через transport/fake transport. diff --git a/docs/site/how-to/account-profile.md b/docs/site/how-to/account-profile.md index b4a9ae4..b266a68 100644 --- a/docs/site/how-to/account-profile.md +++ b/docs/site/how-to/account-profile.md @@ -51,7 +51,7 @@ from avito import AvitoClient with AvitoClient.from_env() as avito: history = avito.account(user_id=7).get_operations_history( date_from=datetime(2026, 4, 1, tzinfo=timezone.utc), - limit=2, + date_to=datetime(2026, 4, 30, tzinfo=timezone.utc), ) operations = history.materialize() @@ -90,7 +90,7 @@ with AvitoClient.from_env() as avito: item_ids=[101], idempotency_key="account-profile-example-1", ) - items = hierarchy.list_items_by_employee(employee_id=10, limit=5) + items = hierarchy.list_items_by_employee(employee_id=10, category_id=24) print(result.success) print(items[0].title) diff --git a/docs/site/how-to/ad-listing-and-stats.md b/docs/site/how-to/ad-listing-and-stats.md index e9dcac1..42deabe 100644 --- a/docs/site/how-to/ad-listing-and-stats.md +++ b/docs/site/how-to/ad-listing-and-stats.md @@ -54,8 +54,16 @@ with AvitoClient.from_env() as avito: date_from=date(2026, 4, 1), date_to="2026-04-23", ) - calls = stats.get_calls_stats() - spendings = stats.get_account_spendings() + calls = stats.get_calls_stats( + date_from=date(2026, 4, 1), + date_to="2026-04-23", + ) + spendings = stats.get_account_spendings( + date_from=date(2026, 4, 1), + date_to="2026-04-23", + spending_types=["promotion"], + grouping="day", + ) print(item_stats.items[0].views) print(calls.items[0].answered_calls) @@ -73,8 +81,12 @@ from avito import AvitoClient with AvitoClient.from_env() as avito: analytics = avito.ad_stats(user_id=7).get_item_analytics( - item_ids=[101], - fields=["views", "contacts", "favorites"], + date_from="2026-04-01", + date_to="2026-04-23", + metrics=["views", "contacts", "favorites"], + grouping="day", + limit=100, + offset=0, ) print(analytics.items[0].contacts) @@ -90,7 +102,7 @@ from avito import AvitoClient with AvitoClient.from_env() as avito: promotion = avito.ad_promotion(item_id=101, user_id=7) prices = promotion.get_vas_prices(item_ids=[101]) - preview = promotion.apply_vas(codes=["xl"], dry_run=True) + preview = promotion.apply_vas(vas_id="xl", dry_run=True) print(prices.items[0].price) print(preview.applied) diff --git a/docs/site/how-to/auth-and-config.md b/docs/site/how-to/auth-and-config.md index a063cbd..9f54519 100644 --- a/docs/site/how-to/auth-and-config.md +++ b/docs/site/how-to/auth-and-config.md @@ -58,8 +58,6 @@ print(info.user_id) **`AVITO_CLIENT_SECRET`** — секрет OAuth-приложения. Храните только в переменных окружения или в vault — не в репозитории. SDK автоматически скрывает это значение из логов и `debug_info()`. -**`AVITO_SECRET`** — поддерживаемый alias для `AVITO_CLIENT_SECRET`. Если заданы оба значения, приоритет остаётся у `AVITO_CLIENT_SECRET`. - ### Опциональные — основные **`AVITO_BASE_URL`** — базовый URL API, по умолчанию `https://api.avito.ru`. Переопределяется при работе с sandbox или внутренним proxy. diff --git a/docs/site/how-to/cpa-calltracking.md b/docs/site/how-to/cpa-calltracking.md index 089a4e2..7fb2fe5 100644 --- a/docs/site/how-to/cpa-calltracking.md +++ b/docs/site/how-to/cpa-calltracking.md @@ -4,7 +4,7 @@ ## CPA-звонки за период -`cpa_call().list()` принимает границы периода строками в формате upstream API и возвращает типизированный список звонков. +`cpa_call().list()` принимает нижнюю границу периода строкой в формате upstream API и возвращает типизированный список звонков. ```python from avito import AvitoClient @@ -12,7 +12,7 @@ from avito import AvitoClient with AvitoClient.from_env() as avito: calls = avito.cpa_call().list( date_time_from="2026-04-18T00:00:00Z", - date_time_to="2026-04-19T00:00:00Z", + limit=100, ) print(calls.items[0].call_id) diff --git a/docs/site/how-to/diagnostics-and-logging.md b/docs/site/how-to/diagnostics-and-logging.md index be5164f..08ce708 100644 --- a/docs/site/how-to/diagnostics-and-logging.md +++ b/docs/site/how-to/diagnostics-and-logging.md @@ -29,6 +29,9 @@ print(info.requires_auth) - `details` — структурированные подробности ошибки из тела ответа API; - `retry_after` — задержка повтора для `429`, если API вернул `Retry-After`; - `request_id` — идентификатор запроса из upstream-заголовков; +- `attempt` — номер HTTP-попытки transport-слоя; +- `method` — HTTP-метод запроса; +- `endpoint` — путь endpoint без query payload и headers; - `metadata` — дополнительные поля с редактированными секретами. ```text @@ -44,6 +47,9 @@ with AvitoClient.from_env() as avito: print(exc.error_code) print(exc.details) print(exc.request_id) + print(exc.attempt) + print(exc.method) + print(exc.endpoint) print(str(exc)) ``` @@ -56,7 +62,7 @@ from avito.core.exceptions import ( AuthenticationError, AuthorizationError, RateLimitError, - NotFoundError, + UpstreamApiError, AvitoError, ) @@ -78,13 +84,21 @@ except AvitoError as exc: ## Безопасное логирование -`debug_info()` — единственный публичный способ получить диагностику без секретов. Не логируйте `to_dict()` / `model_dump()` моделей, которые могут содержать чувствительные данные пользователя. +`debug_info()` возвращает диагностический снимок без секретов. Не логируйте +`to_dict()` / `model_dump()` моделей, которые могут содержать чувствительные +данные пользователя. + +Transport пишет безопасные debug-события в logger `avito.transport` для каждой +HTTP-попытки. В события входят `operation`, `endpoint`, `method`, `attempt`, +`status`, `latency_ms` и `request_id`. Body, OAuth headers, idempotency keys и +другие секреты не логируются. ```python from avito import AvitoClient import logging logger = logging.getLogger(__name__) +logging.getLogger("avito.transport").setLevel(logging.DEBUG) with AvitoClient.from_env() as avito: info = avito.debug_info() @@ -93,18 +107,18 @@ with AvitoClient.from_env() as avito: ## После close() -После `close()` или выхода из контекстного менеджера любой SDK-вызов поднимает `ConfigurationError`. Проверяйте это в долгоживущих сервисах. +После `close()` или выхода из контекстного менеджера любой SDK-вызов поднимает `ClientClosedError`. Проверяйте это в долгоживущих сервисах. ```python from avito import AvitoClient -from avito.core.exceptions import ConfigurationError +from avito.core.exceptions import ClientClosedError avito = AvitoClient.from_env() avito.close() try: avito.account().get_self() -except ConfigurationError as exc: +except ClientClosedError as exc: print(str(exc)) ``` diff --git a/docs/site/how-to/idempotency.md b/docs/site/how-to/idempotency.md index 537cc18..0c57245 100644 --- a/docs/site/how-to/idempotency.md +++ b/docs/site/how-to/idempotency.md @@ -56,13 +56,13 @@ mark-read-{user_id}-{chat_id}-{date} ```text # dry-run — проверяем payload, ключ не нужен preview = avito.ad_promotion(item_id=101, user_id=7).apply_vas( - codes=["xl"], + vas_id="xl", dry_run=True, ) # реальный вызов — добавляем ключ result = avito.ad_promotion(item_id=101, user_id=7).apply_vas( - codes=["xl"], + vas_id="xl", idempotency_key="apply-vas-user7-item101-xl-2026-04-24", ) ``` diff --git a/docs/site/how-to/job-applications.md b/docs/site/how-to/job-applications.md index 152ac21..d236b20 100644 --- a/docs/site/how-to/job-applications.md +++ b/docs/site/how-to/job-applications.md @@ -19,16 +19,13 @@ print(vacancy.url) ## Идентификаторы откликов -Для инкрементальной синхронизации используйте `ApplicationIdsQuery`: он возвращает id откликов, обновлённых после указанного момента. +Для инкрементальной синхронизации используйте `application().get_ids()`: он возвращает id откликов, обновлённых после указанного момента. ```python from avito import AvitoClient -from avito.jobs import ApplicationIdsQuery - -query = ApplicationIdsQuery(updated_at_from="2026-04-23T00:00:00+03:00") with AvitoClient.from_env() as avito: - ids = avito.application().list(query=query) + ids = avito.application().get_ids(updated_at_from="2026-04-23T00:00:00+03:00") print(ids.items[0].id) print(ids.cursor) @@ -36,13 +33,13 @@ print(ids.cursor) ## Данные откликов -Когда id уже известны, запросите подробные данные через тот же метод `list(ids=...)`. +Когда id уже известны, запросите подробные данные через `get_by_ids()`. ```python from avito import AvitoClient with AvitoClient.from_env() as avito: - applications = avito.application().list(ids=["app-1"]) + applications = avito.application().get_by_ids(ids=["app-1"]) print(applications.items[0].applicant_name) print(applications.items[0].state) @@ -79,10 +76,9 @@ print(invited.status) ```python from avito import AvitoClient -from avito.jobs import ResumeSearchQuery with AvitoClient.from_env() as avito: - resumes = avito.resume().list(query=ResumeSearchQuery(query="оператор")) + resumes = avito.resume().list(query="оператор") resume = avito.resume("res-1").get() contacts = avito.resume("res-1").get_contacts() @@ -102,6 +98,7 @@ with AvitoClient.from_env() as avito: current = avito.job_webhook().get() updated = avito.job_webhook().update( url="https://example.com/job", + secret="cb1e150b-c5bf-4c3e-acd1-20ec88bdb3a1", idempotency_key="job-webhook-example-1", ) diff --git a/docs/site/how-to/promotion-dry-run.md b/docs/site/how-to/promotion-dry-run.md index 12f2dec..e246024 100644 --- a/docs/site/how-to/promotion-dry-run.md +++ b/docs/site/how-to/promotion-dry-run.md @@ -25,7 +25,7 @@ from avito import AvitoClient with AvitoClient.from_env() as avito: preview = avito.ad_promotion(item_id=101, user_id=7).apply_vas( - codes=["xl"], + vas_id="xl", dry_run=True, ) @@ -58,7 +58,7 @@ print(preview.target["item_id"]) ```text with AvitoClient.from_env() as avito: result = avito.ad_promotion(item_id=101, user_id=7).apply_vas( - codes=["xl"], + vas_id="xl", idempotency_key="promotion-apply-vas-2026-04-23-101", ) ``` diff --git a/docs/site/how-to/ratings-and-tariffs.md b/docs/site/how-to/ratings-and-tariffs.md index 9612d3b..d2f60c9 100644 --- a/docs/site/how-to/ratings-and-tariffs.md +++ b/docs/site/how-to/ratings-and-tariffs.md @@ -19,14 +19,13 @@ print(rating.reviews_count) ## Список отзывов -Отзывы читаются через `review().list()`. Дефолтный вызов запрашивает первую страницу с `limit=50`. Для перехода по страницам или другого размера страницы используйте `ReviewsQuery`. +Отзывы читаются через `review().list()`. Дефолтный вызов запрашивает первую страницу с `limit=50`. Для перехода по страницам или другого размера страницы передайте `page`, `offset` или `limit`. ```python from avito import AvitoClient -from avito.ratings.models import ReviewsQuery with AvitoClient.from_env() as avito: - reviews = avito.review().list(query=ReviewsQuery(page=1, limit=20)) + reviews = avito.review().list(page=1, limit=20) print(reviews.items[0].review_id) print(reviews.items[0].text) diff --git a/docs/site/reference/client.md b/docs/site/reference/client.md index 01d4863..90f37a3 100644 --- a/docs/site/reference/client.md +++ b/docs/site/reference/client.md @@ -10,7 +10,7 @@ - `AvitoClient(client_id=..., client_secret=...)` — короткий явный путь для OAuth credentials. - `AvitoClient(AvitoSettings(...))` — полный путь для расширенной конфигурации. - Клиент поддерживает context manager и закрывает внутренние HTTP-клиенты в `close()`. -- После `close()` публичные операции поднимают `ConfigurationError`. +- После `close()` публичные операции поднимают `ClientClosedError`. - `debug_info()` возвращает безопасный диагностический снимок без OAuth-секретов. ## Фасад diff --git a/docs/site/reference/config.md b/docs/site/reference/config.md index f037513..50bdeda 100644 --- a/docs/site/reference/config.md +++ b/docs/site/reference/config.md @@ -22,7 +22,7 @@ OAuth-credentials и полный объект настроек. Приорит | Переменная | Поле | Описание | |---|---|---| | `AVITO_CLIENT_ID` | `auth.client_id` | Client ID OAuth-приложения. Получить на [avito.ru/professionals/api](https://www.avito.ru/professionals/api). | -| `AVITO_CLIENT_SECRET`, `AVITO_SECRET` | `auth.client_secret` | Client Secret OAuth-приложения. Хранить только в защищённом месте — в логах и `debug_info()` не отображается. | +| `AVITO_CLIENT_SECRET` | `auth.client_secret` | Client Secret OAuth-приложения. Хранить только в защищённом месте — в логах и `debug_info()` не отображается. | ### Опциональные — основные diff --git a/docs/site/reference/exceptions.md b/docs/site/reference/exceptions.md index dc8f850..bd6432b 100644 --- a/docs/site/reference/exceptions.md +++ b/docs/site/reference/exceptions.md @@ -2,7 +2,8 @@ `AvitoError` — базовый тип ошибок SDK. Специализированные исключения отражают класс сбоя: аутентификация, авторизация, validation, rate limit, transport и -ошибки upstream API. +ошибки upstream API. Вызовы после `AvitoClient.close()` поднимают +`ClientClosedError`. ## Диагностические поля diff --git a/poetry.lock b/poetry.lock index e66cb88..eeefe62 100644 --- a/poetry.lock +++ b/poetry.lock @@ -18,13 +18,25 @@ idna = ">=2.8" [package.extras] trio = ["trio (>=0.32.0)"] +[[package]] +name = "appdirs" +version = "1.4.4" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, + {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, +] + [[package]] name = "attrs" version = "26.1.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.9" -groups = ["docs"] +groups = ["dev", "docs"] files = [ {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, @@ -90,6 +102,25 @@ test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", toml = ["tomli (>=1.1.0) ; python_version < \"3.11\""] yaml = ["PyYAML"] +[[package]] +name = "bowler" +version = "0.9.0" +description = "Safe code refactoring for modern Python projects" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "bowler-0.9.0-py3-none-any.whl", hash = "sha256:0351839e9917765be694aa52c99ea784dc1286c9bdd6fd066b810097fc273e1b"}, + {file = "bowler-0.9.0.tar.gz", hash = "sha256:cdb85ce2e7bd545802a15d755d1daf2b6a125429355c50d2019a9f35d63e45db"}, +] + +[package.dependencies] +attrs = "*" +click = "*" +fissix = "*" +moreorless = ">=0.2.0" +volatile = "*" + [[package]] name = "bracex" version = "2.6" @@ -259,7 +290,7 @@ version = "8.3.3" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" -groups = ["docs"] +groups = ["dev", "docs"] files = [ {file = "click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613"}, {file = "click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2"}, @@ -279,7 +310,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {dev = "sys_platform == \"win32\""} +markers = {dev = "sys_platform == \"win32\" or platform_system == \"Windows\""} [[package]] name = "coverage" @@ -400,6 +431,25 @@ files = [ [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +[[package]] +name = "fissix" +version = "24.4.24" +description = "Monkeypatches to override default behavior of lib2to3." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "fissix-24.4.24-py3-none-any.whl", hash = "sha256:be7f5c66e9e212bd9b3365c9e8f2453e973d0a645f31c8eba842724adb4c0c50"}, + {file = "fissix-24.4.24.tar.gz", hash = "sha256:7e8f1e448d1ebc1c8be68be8bf71123650710076ea9dcecb7801804b04f43547"}, +] + +[package.dependencies] +appdirs = ">=1.4.4" + +[package.extras] +dev = ["attribution (==1.7.1)", "black (==24.4.0)", "flit (==3.9.0)", "isort (==5.8.0)", "pytest (==8.1.1)"] +docs = ["sphinx (==7.3.7)", "sphinx-mdinclude (==0.6.0)"] + [[package]] name = "ghp-import" version = "2.1.0" @@ -562,6 +612,84 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "libcst" +version = "1.8.6" +description = "A concrete syntax tree with AST-like properties for Python 3.0 through 3.14 programs." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "libcst-1.8.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a20c5182af04332cc94d8520792befda06d73daf2865e6dddc5161c72ea92cb9"}, + {file = "libcst-1.8.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36473e47cb199b7e6531d653ee6ffed057de1d179301e6c67f651f3af0b499d6"}, + {file = "libcst-1.8.6-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:06fc56335a45d61b7c1b856bfab4587b84cfe31e9d6368f60bb3c9129d900f58"}, + {file = "libcst-1.8.6-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6b23d14a7fc0addd9795795763af26b185deb7c456b1e7cc4d5228e69dab5ce8"}, + {file = "libcst-1.8.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:16cfe0cfca5fd840e1fb2c30afb628b023d3085b30c3484a79b61eae9d6fe7ba"}, + {file = "libcst-1.8.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:455f49a93aea4070132c30ebb6c07c2dea0ba6c1fde5ffde59fc45dbb9cfbe4b"}, + {file = "libcst-1.8.6-cp310-cp310-win_amd64.whl", hash = "sha256:72cca15800ffc00ba25788e4626189fe0bc5fe2a0c1cb4294bce2e4df21cc073"}, + {file = "libcst-1.8.6-cp310-cp310-win_arm64.whl", hash = "sha256:6cad63e3a26556b020b634d25a8703b605c0e0b491426b3e6b9e12ed20f09100"}, + {file = "libcst-1.8.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3649a813660fbffd7bc24d3f810b1f75ac98bd40d9d6f56d1f0ee38579021073"}, + {file = "libcst-1.8.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0cbe17067055829607c5ba4afa46bfa4d0dd554c0b5a583546e690b7367a29b6"}, + {file = "libcst-1.8.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:59a7e388c57d21d63722018978a8ddba7b176e3a99bd34b9b84a576ed53f2978"}, + {file = "libcst-1.8.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b6c1248cc62952a3a005792b10cdef2a4e130847be9c74f33a7d617486f7e532"}, + {file = "libcst-1.8.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6421a930b028c5ef4a943b32a5a78b7f1bf15138214525a2088f11acbb7d3d64"}, + {file = "libcst-1.8.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6d8b67874f2188399a71a71731e1ba2d1a2c3173b7565d1cc7ffb32e8fbaba5b"}, + {file = "libcst-1.8.6-cp311-cp311-win_amd64.whl", hash = "sha256:b0d8c364c44ae343937f474b2e492c1040df96d94530377c2f9263fb77096e4f"}, + {file = "libcst-1.8.6-cp311-cp311-win_arm64.whl", hash = "sha256:5dcaaebc835dfe5755bc85f9b186fb7e2895dda78e805e577fef1011d51d5a5c"}, + {file = "libcst-1.8.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c13d5bd3d8414a129e9dccaf0e5785108a4441e9b266e1e5e9d1f82d1b943c9"}, + {file = "libcst-1.8.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1472eeafd67cdb22544e59cf3bfc25d23dc94058a68cf41f6654ff4fcb92e09"}, + {file = "libcst-1.8.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:089c58e75cb142ec33738a1a4ea7760a28b40c078ab2fd26b270dac7d2633a4d"}, + {file = "libcst-1.8.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c9d7aeafb1b07d25a964b148c0dda9451efb47bbbf67756e16eeae65004b0eb5"}, + {file = "libcst-1.8.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:207481197afd328aa91d02670c15b48d0256e676ce1ad4bafb6dc2b593cc58f1"}, + {file = "libcst-1.8.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:375965f34cc6f09f5f809244d3ff9bd4f6cb6699f571121cebce53622e7e0b86"}, + {file = "libcst-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:da95b38693b989eaa8d32e452e8261cfa77fe5babfef1d8d2ac25af8c4aa7e6d"}, + {file = "libcst-1.8.6-cp312-cp312-win_arm64.whl", hash = "sha256:bff00e1c766658adbd09a175267f8b2f7616e5ee70ce45db3d7c4ce6d9f6bec7"}, + {file = "libcst-1.8.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7445479ebe7d1aff0ee094ab5a1c7718e1ad78d33e3241e1a1ec65dcdbc22ffb"}, + {file = "libcst-1.8.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fc3fef8a2c983e7abf5d633e1884c5dd6fa0dcb8f6e32035abd3d3803a3a196"}, + {file = "libcst-1.8.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1a3a5e4ee870907aa85a4076c914ae69066715a2741b821d9bf16f9579de1105"}, + {file = "libcst-1.8.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6609291c41f7ad0bac570bfca5af8fea1f4a27987d30a1fa8b67fe5e67e6c78d"}, + {file = "libcst-1.8.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25eaeae6567091443b5374b4c7d33a33636a2d58f5eda02135e96fc6c8807786"}, + {file = "libcst-1.8.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04030ea4d39d69a65873b1d4d877def1c3951a7ada1824242539e399b8763d30"}, + {file = "libcst-1.8.6-cp313-cp313-win_amd64.whl", hash = "sha256:8066f1b70f21a2961e96bedf48649f27dfd5ea68be5cd1bed3742b047f14acde"}, + {file = "libcst-1.8.6-cp313-cp313-win_arm64.whl", hash = "sha256:c188d06b583900e662cd791a3f962a8c96d3dfc9b36ea315be39e0a4c4792ebf"}, + {file = "libcst-1.8.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c41c76e034a1094afed7057023b1d8967f968782433f7299cd170eaa01ec033e"}, + {file = "libcst-1.8.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5432e785322aba3170352f6e72b32bea58d28abd141ac37cc9b0bf6b7c778f58"}, + {file = "libcst-1.8.6-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:85b7025795b796dea5284d290ff69de5089fc8e989b25d6f6f15b6800be7167f"}, + {file = "libcst-1.8.6-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:536567441182a62fb706e7aa954aca034827b19746832205953b2c725d254a93"}, + {file = "libcst-1.8.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f04d3672bde1704f383a19e8f8331521abdbc1ed13abb349325a02ac56e5012"}, + {file = "libcst-1.8.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f04febcd70e1e67917be7de513c8d4749d2e09206798558d7fe632134426ea4"}, + {file = "libcst-1.8.6-cp313-cp313t-win_amd64.whl", hash = "sha256:1dc3b897c8b0f7323412da3f4ad12b16b909150efc42238e19cbf19b561cc330"}, + {file = "libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42"}, + {file = "libcst-1.8.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b188e626ce61de5ad1f95161b8557beb39253de4ec74fc9b1f25593324a0279c"}, + {file = "libcst-1.8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87e74f7d7dfcba9efa91127081e22331d7c42515f0a0ac6e81d4cf2c3ed14661"}, + {file = "libcst-1.8.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a926a4b42015ee24ddfc8ae940c97bd99483d286b315b3ce82f3bafd9f53474"}, + {file = "libcst-1.8.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fbb7f569e69fd9e89d9d9caa57ca42c577c28ed05062f96a8c207594e75b8"}, + {file = "libcst-1.8.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08bd63a8ce674be431260649e70fca1d43f1554f1591eac657f403ff8ef82c7a"}, + {file = "libcst-1.8.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e00e275d4ba95d4963431ea3e409aa407566a74ee2bf309a402f84fc744abe47"}, + {file = "libcst-1.8.6-cp314-cp314-win_amd64.whl", hash = "sha256:fea5c7fa26556eedf277d4f72779c5ede45ac3018650721edd77fd37ccd4a2d4"}, + {file = "libcst-1.8.6-cp314-cp314-win_arm64.whl", hash = "sha256:bb9b4077bdf8857b2483879cbbf70f1073bc255b057ec5aac8a70d901bb838e9"}, + {file = "libcst-1.8.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:55ec021a296960c92e5a33b8d93e8ad4182b0eab657021f45262510a58223de1"}, + {file = "libcst-1.8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ba9ab2b012fbd53b36cafd8f4440a6b60e7e487cd8b87428e57336b7f38409a4"}, + {file = "libcst-1.8.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c0a0cc80aebd8aa15609dd4d330611cbc05e9b4216bcaeabba7189f99ef07c28"}, + {file = "libcst-1.8.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:42a4f68121e2e9c29f49c97f6154e8527cd31021809cc4a941c7270aa64f41aa"}, + {file = "libcst-1.8.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a434c521fadaf9680788b50d5c21f4048fa85ed19d7d70bd40549fbaeeecab1"}, + {file = "libcst-1.8.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a65f844d813ab4ef351443badffa0ae358f98821561d19e18b3190f59e71996"}, + {file = "libcst-1.8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb14bc4d4d83a57062fed2c5da93ecb426ff65b0dc02ddf3481040f5f074a82"}, + {file = "libcst-1.8.6-cp314-cp314t-win_arm64.whl", hash = "sha256:819c8081e2948635cab60c603e1bbdceccdfe19104a242530ad38a36222cb88f"}, + {file = "libcst-1.8.6-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:cb2679ef532f9fa5be5c5a283b6357cb6e9888a8dd889c4bb2b01845a29d8c0b"}, + {file = "libcst-1.8.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:203ec2a83f259baf686b9526268cd23d048d38be5589594ef143aee50a4faf7e"}, + {file = "libcst-1.8.6-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:6366ab2107425bf934b0c83311177f2a371bfc757ee8c6ad4a602d7cbcc2f363"}, + {file = "libcst-1.8.6-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:6aa11df6c58812f731172b593fcb485d7ba09ccc3b52fea6c7f26a43377dc748"}, + {file = "libcst-1.8.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:351ab879c2fd20d9cb2844ed1ea3e617ed72854d3d1e2b0880ede9c3eea43ba8"}, + {file = "libcst-1.8.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:98fa1ca321c81fb1f02e5c43f956ca543968cc1a30b264fd8e0a2e1b0b0bf106"}, + {file = "libcst-1.8.6-cp39-cp39-win_amd64.whl", hash = "sha256:25fc7a1303cad7639ad45ec38c06789b4540b7258e9a108924aaa2c132af4aca"}, + {file = "libcst-1.8.6-cp39-cp39-win_arm64.whl", hash = "sha256:4d7bbdd35f3abdfb5ac5d1a674923572dab892b126a58da81ff2726102d6ec2e"}, + {file = "libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b"}, +] + +[package.dependencies] +pyyaml = {version = ">=6.0.3", markers = "python_version >= \"3.14\""} + [[package]] name = "librt" version = "0.9.0" @@ -1083,6 +1211,21 @@ files = [ [package.extras] test = ["pytest"] +[[package]] +name = "moreorless" +version = "0.5.0" +description = "Python diff wrapper" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "moreorless-0.5.0-py3-none-any.whl", hash = "sha256:66228870cd2f14bad5c3c3780aa71e29d3b2d9b5a01c03bfbf105efd4f668ecf"}, + {file = "moreorless-0.5.0.tar.gz", hash = "sha256:560a04f85006fccd74feaa4b6213a446392ff7b5ec0194a5464b6c30f182fa33"}, +] + +[package.dependencies] +click = "*" + [[package]] name = "mypy" version = "1.20.1" @@ -1404,7 +1547,7 @@ version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["docs"] +groups = ["dev", "docs"] files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, @@ -1677,6 +1820,17 @@ files = [ [package.extras] test = ["coverage", "flake8 (>=3.7)", "mypy", "pretend", "pytest"] +[[package]] +name = "volatile" +version = "2.1.0" +description = "A small extension for the tempfile module." +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "volatile-2.1.0.tar.gz", hash = "sha256:9be36ad508e3354e016c115de0397dc2203b9800a73d9d177ca9d37a8d3a31d3"}, +] + [[package]] name = "watchdog" version = "6.0.0" @@ -1738,4 +1892,4 @@ bracex = ">=2.1.1" [metadata] lock-version = "2.1" python-versions = "^3.14" -content-hash = "4e9624b5ae0a403e682c467ed725140d84f73f5ba58c87809fa079684f550a80" +content-hash = "3e4d9e69e4fab1af2dfb5fd3fc81e525af569d7d35f34a56c5c5199f6cb46cf4" diff --git a/pyproject.toml b/pyproject.toml index d2f84ee..9211d97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "avito-py" -version = "1.0.2" +version = "2.0.0" description = "SDK для разработки инструментов на базе Avito API" authors = ["Nikolay Baryshnikov "] packages=[ @@ -32,6 +32,8 @@ coverage = "^7.10.6" mypy = "^1.18.2" ruff = "^0.12.12" respx = "^0.22.0" +libcst = "^1.8.6" +bowler = "^0.9.0" [tool.poetry.group.docs.dependencies] mkdocs-material = "^9.5" diff --git a/reference-explanation-examples-report.json b/reference-explanation-examples-report.json deleted file mode 100644 index 7e28c87..0000000 --- a/reference-explanation-examples-report.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "checked_dirs": [ - "reference", - "explanations" - ], - "executable_sources": [ - "README.md", - "docs/site/tutorials/first-promotion.md", - "docs/site/tutorials/getting-started.md", - "docs/site/tutorials/index.md", - "docs/site/how-to/account-profile.md", - "docs/site/how-to/ad-listing-and-stats.md", - "docs/site/how-to/auth-and-config.md", - "docs/site/how-to/autoteka-report.md", - "docs/site/how-to/chat-image-upload.md", - "docs/site/how-to/cpa-calltracking.md", - "docs/site/how-to/diagnostics-and-logging.md", - "docs/site/how-to/idempotency.md", - "docs/site/how-to/index.md", - "docs/site/how-to/job-applications.md", - "docs/site/how-to/order-labels.md", - "docs/site/how-to/pagination.md", - "docs/site/how-to/per-operation-overrides.md", - "docs/site/how-to/promotion-dry-run.md", - "docs/site/how-to/ratings-and-tariffs.md", - "docs/site/how-to/realty-booking.md", - "docs/site/how-to/security-practices.md", - "docs/site/how-to/testing-with-fake-transport.md" - ], - "gap_count": 0, - "gaps": [] -} \ No newline at end of file diff --git a/scripts/lint_architecture.py b/scripts/lint_architecture.py new file mode 100644 index 0000000..3ab34b3 --- /dev/null +++ b/scripts/lint_architecture.py @@ -0,0 +1,754 @@ +"""Enforce domain architecture v2 invariants during migration.""" + +from __future__ import annotations + +import argparse +import ast +import json +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path + +API_DOMAINS = ( + "accounts", + "ads", + "autoteka", + "cpa", + "jobs", + "messenger", + "orders", + "promotion", + "ratings", + "realty", + "tariffs", +) +LEGACY_FILENAMES = ( + "client.py", + "mappers.py", + "enums.py", + "_client.py", + "_mappers.py", + "_mapping.py", + "_enums.py", +) +LEGACY_USAGE_PATTERNS = ("request_public_model", "mapper=") +DEFAULT_MIGRATION_ALLOWLIST: frozenset[str] = frozenset() +APPROVED_PUBLIC_WRAPPERS = frozenset( + { + ("jobs", "Application", "list"), + } +) +TEXT_FILE_SUFFIXES = frozenset({".py", ".md", ".txt"}) +PRODUCTION_CHECK_DIRS = ("avito",) +SOURCE_CHECK_DIRS = ("avito", "tests", "docs") +DATE_LIKE_PARAMETER_MARKERS = frozenset( + { + "created_at_from", + "date", + "date_end", + "date_from", + "date_start", + "date_time_from", + "date_time_to", + "date_to", + "finish_time", + "start_time", + "updated_at_from", + "updated_from", + "updated_to", + } +) +DATE_VALIDATION_CALLS = frozenset( + { + "serialize_iso_date", + "serialize_iso_datetime", + "validate_iso_date", + "validate_iso_datetime", + } +) +DATE_SAFE_ANNOTATION_NAMES = frozenset({"DateInput", "date", "datetime"}) +FORBIDDEN_OFFICIAL_ENV_ALIASES = frozenset({"SECRET", "TOKEN", "AVITO_SECRET", "AVITO_TOKEN"}) +REQUIRED_AVITO_ERROR_FIELDS = frozenset({"attempt", "method", "endpoint", "request_id"}) + + +@dataclass(frozen=True, slots=True) +class ArchitectureLintError: + """Single architecture lint violation.""" + + code: str + message: str + path: str + line: int = 1 + + def to_dict(self) -> dict[str, object]: + """Return JSON-compatible error data.""" + + return { + "code": self.code, + "message": self.message, + "path": self.path, + "line": self.line, + } + + +@dataclass(frozen=True, slots=True) +class OperationModelUse: + """Model referenced by an OperationSpec field.""" + + domain: str + model_name: str + field_name: str + path: Path + line: int + + +@dataclass(frozen=True, slots=True) +class ClassInfo: + """Minimal AST information about a model class.""" + + name: str + bases: frozenset[str] + methods: frozenset[str] + path: Path + line: int + + +def parse_args() -> argparse.Namespace: + """Parse CLI arguments.""" + + parser = argparse.ArgumentParser( + description="Проверить соблюдение domain architecture v2.", + ) + parser.add_argument( + "--root", + type=Path, + default=Path("."), + help="Корень репозитория.", + ) + parser.add_argument( + "--json", + action="store_true", + dest="json_report", + help="Вывести report в JSON.", + ) + parser.add_argument( + "--output", + type=Path, + help="Записать report в файл вместо stdout.", + ) + parser.add_argument( + "--allowlist-domain", + action="append", + choices=API_DOMAINS, + dest="allowlisted_domains", + help=( + "API-домен, временно исключённый из migration checks. " + "По умолчанию разрешены все ещё не переведённые Phase 0 домены." + ), + ) + parser.add_argument( + "--no-default-allowlist", + action="store_true", + help="Отключить migration allowlist по умолчанию.", + ) + return parser.parse_args() + + +def main() -> int: + """Run architecture lint CLI.""" + + args = parse_args() + if args.no_default_allowlist: + allowlisted_domains = frozenset(args.allowlisted_domains or ()) + else: + allowlisted_domains = DEFAULT_MIGRATION_ALLOWLIST.union( + frozenset(args.allowlisted_domains or ()) + ) + + errors = lint_architecture(args.root, allowlisted_domains=allowlisted_domains) + report = { + "summary": { + "error_count": len(errors), + "allowlisted_domains": sorted(allowlisted_domains), + }, + "errors": [error.to_dict() for error in errors], + } + + if args.json_report: + output = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + else: + output = render_text_report(errors, allowlisted_domains=allowlisted_domains) + + if args.output is None: + print(output, end="") + else: + args.output.write_text(output, encoding="utf-8") + return 1 if errors else 0 + + +def lint_architecture( + root: Path = Path("."), + *, + allowlisted_domains: Iterable[str] = DEFAULT_MIGRATION_ALLOWLIST, +) -> tuple[ArchitectureLintError, ...]: + """Return architecture lint violations for repository root.""" + + normalized_root = root.resolve() + allowlist = frozenset(allowlisted_domains) + errors: list[ArchitectureLintError] = [] + errors.extend(_lint_legacy_files(normalized_root, allowlist)) + errors.extend(_lint_legacy_imports(normalized_root, allowlist)) + errors.extend(_lint_legacy_usage(normalized_root, allowlist)) + errors.extend(_lint_runtime_patching(normalized_root)) + errors.extend(_lint_official_env_aliases(normalized_root)) + errors.extend(_lint_public_exception_fields(normalized_root)) + errors.extend(_lint_public_domain_methods(normalized_root, allowlist)) + errors.extend(_lint_operation_models(normalized_root, allowlist)) + return tuple(sorted(errors, key=lambda error: (error.path, error.line, error.code))) + + +def render_text_report( + errors: Sequence[ArchitectureLintError], + *, + allowlisted_domains: Iterable[str] = DEFAULT_MIGRATION_ALLOWLIST, +) -> str: + """Render human-readable architecture lint report.""" + + allowlist = sorted(allowlisted_domains) + lines = [ + "Architecture lint: " + f"errors={len(errors)}, allowlisted_domains={', '.join(allowlist) or '-'}" + ] + for error in errors: + lines.append(f"{error.path}:{error.line}: [{error.code}] {error.message}") + return "\n".join(lines) + "\n" + + +def _lint_legacy_files( + root: Path, + allowlisted_domains: frozenset[str], +) -> tuple[ArchitectureLintError, ...]: + errors: list[ArchitectureLintError] = [] + for domain in API_DOMAINS: + if domain in allowlisted_domains: + continue + for filename in LEGACY_FILENAMES: + path = root / "avito" / domain / filename + if not path.exists(): + continue + errors.append( + ArchitectureLintError( + code="ARCH_LEGACY_FILE", + message=( + f"API-домен `{domain}` не должен содержать legacy файл `{filename}`." + ), + path=_relative_path(path, root), + ) + ) + return tuple(errors) + + +def _lint_legacy_imports( + root: Path, + allowlisted_domains: frozenset[str], +) -> tuple[ArchitectureLintError, ...]: + errors: list[ArchitectureLintError] = [] + for path in _iter_python_files(root, SOURCE_CHECK_DIRS): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + modules: list[tuple[str, int]] = [] + if isinstance(node, ast.ImportFrom) and node.module is not None: + modules.append((node.module, node.lineno)) + elif isinstance(node, ast.Import): + modules.extend((alias.name, node.lineno) for alias in node.names) + for module, line in modules: + legacy_domain = _legacy_module_domain(module) + if legacy_domain is None or legacy_domain in allowlisted_domains: + continue + errors.append( + ArchitectureLintError( + code="ARCH_LEGACY_IMPORT", + message=f"Запрещён import legacy module `{module}`.", + path=_relative_path(path, root), + line=line, + ) + ) + return tuple(errors) + + +def _lint_legacy_usage( + root: Path, + allowlisted_domains: frozenset[str], +) -> tuple[ArchitectureLintError, ...]: + errors: list[ArchitectureLintError] = [] + for path in _iter_text_files(root, SOURCE_CHECK_DIRS): + relative_path = _relative_path(path, root) + if _path_domain(path, root) in allowlisted_domains: + continue + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + for pattern in LEGACY_USAGE_PATTERNS: + if pattern not in line: + continue + errors.append( + ArchitectureLintError( + code="ARCH_LEGACY_USAGE", + message=f"Запрещено legacy usage `{pattern}`.", + path=relative_path, + line=line_number, + ) + ) + return tuple(errors) + + +def _lint_runtime_patching(root: Path) -> tuple[ArchitectureLintError, ...]: + errors: list[ArchitectureLintError] = [] + for path in _iter_python_files(root, PRODUCTION_CHECK_DIRS): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + name = _call_name(node.func) + if name not in {"setattr", "globals"}: + continue + errors.append( + ArchitectureLintError( + code="ARCH_RUNTIME_PATCHING", + message=f"Запрещён runtime patching через `{name}(...)`.", + path=_relative_path(path, root), + line=node.lineno, + ) + ) + return tuple(errors) + + +def _lint_official_env_aliases(root: Path) -> tuple[ArchitectureLintError, ...]: + errors: list[ArchitectureLintError] = [] + for path in _iter_python_files(root, PRODUCTION_CHECK_DIRS): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for class_node in (node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)): + for assignment in class_node.body: + if not _is_class_alias_assignment(assignment, "ENV_ALIASES"): + continue + for alias_node in _string_constants(assignment.value): + alias = alias_node.value + if alias not in FORBIDDEN_OFFICIAL_ENV_ALIASES: + continue + errors.append( + ArchitectureLintError( + code="ARCH_FORBIDDEN_ENV_ALIAS", + message=( + f"Официальный env alias `{alias}` слишком общий; " + "используйте доменное имя вроде `AVITO_CLIENT_SECRET`." + ), + path=_relative_path(path, root), + line=alias_node.lineno, + ) + ) + return tuple(errors) + + +def _lint_public_exception_fields(root: Path) -> tuple[ArchitectureLintError, ...]: + path = root / "avito" / "core" / "exceptions.py" + if not path.exists(): + return () + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for class_node in (node for node in tree.body if isinstance(node, ast.ClassDef)): + if class_node.name != "AvitoError": + continue + fields = { + statement.target.id + for statement in class_node.body + if isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name) + } + errors: list[ArchitectureLintError] = [] + for field_name in sorted(REQUIRED_AVITO_ERROR_FIELDS - fields): + errors.append( + ArchitectureLintError( + code="ARCH_AVITO_ERROR_FIELD_MISSING", + message=f"`AvitoError` должен явно объявлять поле `{field_name}`.", + path=_relative_path(path, root), + line=class_node.lineno, + ) + ) + return tuple(errors) + return ( + ArchitectureLintError( + code="ARCH_AVITO_ERROR_MISSING", + message="Не найден базовый public exception `AvitoError`.", + path=_relative_path(path, root), + ), + ) + + +def _lint_public_domain_methods( + root: Path, + allowlisted_domains: frozenset[str], +) -> tuple[ArchitectureLintError, ...]: + errors: list[ArchitectureLintError] = [] + for domain in API_DOMAINS: + if domain in allowlisted_domains: + continue + path = root / "avito" / domain / "domain.py" + if not path.exists(): + continue + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for class_node in _public_classes(tree): + for method_node in _public_methods(class_node): + if (domain, class_node.name, method_node.name) in APPROVED_PUBLIC_WRAPPERS: + continue + method_label = f"{class_node.name}.{method_node.name}" + for parameter in _optional_positional_parameters(method_node): + errors.append( + ArchitectureLintError( + code="ARCH_PUBLIC_OPTIONAL_POSITIONAL", + message=( + f"Public API method `{method_label}` содержит optional " + f"positional parameter `{parameter.arg}`; сделайте его keyword-only." + ), + path=_relative_path(path, root), + line=parameter.lineno, + ) + ) + for parameter in _date_like_string_parameters(method_node): + errors.append( + ArchitectureLintError( + code="ARCH_PUBLIC_DATE_STRING_UNVALIDATED", + message=( + f"Public API method `{method_label}` принимает date-like string " + f"parameter `{parameter.arg}` без явного validation/serialization helper." + ), + path=_relative_path(path, root), + line=parameter.lineno, + ) + ) + if not _has_swagger_operation(method_node): + errors.append( + ArchitectureLintError( + code="ARCH_PUBLIC_METHOD_UNBOUND", + message=f"Public API method `{method_label}` без swagger_operation.", + path=_relative_path(path, root), + line=method_node.lineno, + ) + ) + if not _method_uses_operation_executor(method_node): + errors.append( + ArchitectureLintError( + code="ARCH_PUBLIC_METHOD_NO_OPERATION_SPEC", + message=f"Public API method `{method_label}` не исполняется через OperationSpec.", + path=_relative_path(path, root), + line=method_node.lineno, + ) + ) + if _annotation_is_forbidden_public_return(method_node.returns): + errors.append( + ArchitectureLintError( + code="ARCH_PUBLIC_RETURN_RAW", + message=f"Public API method `{method_label}` возвращает dict или Any.", + path=_relative_path(path, root), + line=method_node.lineno, + ) + ) + return tuple(errors) + + +def _optional_positional_parameters(method_node: ast.FunctionDef) -> tuple[ast.arg, ...]: + positional_args = tuple(method_node.args.posonlyargs + method_node.args.args) + positional_args = tuple(arg for arg in positional_args if arg.arg != "self") + default_count = len(method_node.args.defaults) + if default_count == 0: + return () + return positional_args[-default_count:] + + +def _date_like_string_parameters(method_node: ast.FunctionDef) -> tuple[ast.arg, ...]: + if _method_uses_date_validation_helper(method_node): + return () + parameters = tuple( + arg + for arg in ( + method_node.args.posonlyargs + method_node.args.args + method_node.args.kwonlyargs + ) + if arg.arg != "self" + ) + return tuple(parameter for parameter in parameters if _is_unvalidated_date_string(parameter)) + + +def _method_uses_date_validation_helper(method_node: ast.FunctionDef) -> bool: + for node in ast.walk(method_node): + if not isinstance(node, ast.Call): + continue + name = _call_name(node.func) + if name in DATE_VALIDATION_CALLS or name.rsplit(".", maxsplit=1)[-1] in DATE_VALIDATION_CALLS: + return True + return False + + +def _is_unvalidated_date_string(parameter: ast.arg) -> bool: + if parameter.arg not in DATE_LIKE_PARAMETER_MARKERS: + return False + if parameter.annotation is None: + return False + annotation_names = _annotation_names(parameter.annotation) + if "str" not in annotation_names: + return False + return not bool(annotation_names & DATE_SAFE_ANNOTATION_NAMES) + + +def _lint_operation_models( + root: Path, + allowlisted_domains: frozenset[str], +) -> tuple[ArchitectureLintError, ...]: + errors: list[ArchitectureLintError] = [] + for domain in API_DOMAINS: + if domain in allowlisted_domains: + continue + classes = _collect_domain_classes(root, domain) + for use in _collect_operation_model_uses(root, domain): + class_info = classes.get(use.model_name) + if class_info is None: + continue + if use.field_name == "response_model": + if "from_payload" not in class_info.methods: + errors.append( + ArchitectureLintError( + code="ARCH_RESPONSE_MODEL_NO_FROM_PAYLOAD", + message=( + f"Response model `{use.model_name}` из OperationSpec " + "не реализует from_payload()." + ), + path=_relative_path(class_info.path, root), + line=class_info.line, + ) + ) + continue + required_method = "to_params" if use.field_name == "query_model" else "to_payload" + if required_method in class_info.methods or "RequestModel" in class_info.bases: + continue + errors.append( + ArchitectureLintError( + code="ARCH_REQUEST_MODEL_NO_SERIALIZER", + message=( + f"Request/query model `{use.model_name}` из OperationSpec " + f"не реализует {required_method}() и не наследует RequestModel." + ), + path=_relative_path(class_info.path, root), + line=class_info.line, + ) + ) + return tuple(errors) + + +def _collect_operation_model_uses(root: Path, domain: str) -> tuple[OperationModelUse, ...]: + uses: list[OperationModelUse] = [] + for path in _domain_operation_files(root, domain): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not _is_operation_spec_call(node): + continue + for keyword in node.keywords: + if keyword.arg not in {"query_model", "request_model", "response_model"}: + continue + model_name = _model_name(keyword.value) + if model_name is None: + continue + uses.append( + OperationModelUse( + domain=domain, + model_name=model_name, + field_name=keyword.arg, + path=path, + line=keyword.value.lineno, + ) + ) + return tuple(uses) + + +def _collect_domain_classes(root: Path, domain: str) -> Mapping[str, ClassInfo]: + classes: dict[str, ClassInfo] = {} + domain_path = root / "avito" / domain + if not domain_path.exists(): + return classes + for path in sorted(domain_path.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + classes[node.name] = ClassInfo( + name=node.name, + bases=frozenset(_base_name(base) for base in node.bases), + methods=frozenset( + item.name for item in node.body if isinstance(item, ast.FunctionDef) + ), + path=path, + line=node.lineno, + ) + return classes + + +def _domain_operation_files(root: Path, domain: str) -> tuple[Path, ...]: + domain_path = root / "avito" / domain + candidates = [domain_path / "operations.py"] + operations_dir = domain_path / "operations" + if operations_dir.exists(): + candidates.extend(sorted(operations_dir.rglob("*.py"))) + return tuple(path for path in candidates if path.exists()) + + +def _public_classes(tree: ast.Module) -> Iterable[ast.ClassDef]: + for node in tree.body: + if isinstance(node, ast.ClassDef) and not node.name.startswith("_"): + yield node + + +def _public_methods(class_node: ast.ClassDef) -> Iterable[ast.FunctionDef]: + for node in class_node.body: + if isinstance(node, ast.FunctionDef) and not node.name.startswith("_"): + yield node + + +def _has_swagger_operation(method_node: ast.FunctionDef) -> bool: + return any(_decorator_name(decorator) == "swagger_operation" for decorator in method_node.decorator_list) + + +def _method_uses_operation_executor(method_node: ast.FunctionDef) -> bool: + for node in ast.walk(method_node): + if not isinstance(node, ast.Call): + continue + name = _call_name(node.func) + if name in {"self._execute", "_execute", "OperationExecutor.execute"}: + return True + if name.endswith("._execute") or name.endswith(".execute"): + return True + return False + + +def _annotation_is_forbidden_public_return(annotation: ast.expr | None) -> bool: + if annotation is None: + return False + names = _annotation_names(annotation) + return "dict" in names or "Dict" in names or "Any" in names or "typing.Any" in names + + +def _annotation_names(annotation: ast.expr) -> frozenset[str]: + names: set[str] = set() + for node in ast.walk(annotation): + if isinstance(node, ast.Name): + names.add(node.id) + elif isinstance(node, ast.Attribute): + names.add(_attribute_name(node)) + return frozenset(names) + + +def _is_operation_spec_call(node: ast.AST) -> bool: + return isinstance(node, ast.Call) and _call_name(node.func).endswith("OperationSpec") + + +def _model_name(node: ast.AST) -> str | None: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return node.attr + return None + + +def _decorator_name(node: ast.AST) -> str: + if isinstance(node, ast.Call): + return _call_name(node.func) + return _call_name(node) + + +def _is_class_alias_assignment(node: ast.AST, name: str) -> bool: + if isinstance(node, ast.Assign): + return any(isinstance(target, ast.Name) and target.id == name for target in node.targets) + if isinstance(node, ast.AnnAssign): + return isinstance(node.target, ast.Name) and node.target.id == name + return False + + +def _string_constants(node: ast.AST | None) -> Iterable[ast.Constant]: + if node is None: + return () + return ( + child + for child in ast.walk(node) + if isinstance(child, ast.Constant) and isinstance(child.value, str) + ) + + +def _call_name(node: ast.AST) -> str: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return _attribute_name(node) + return "" + + +def _attribute_name(node: ast.Attribute) -> str: + parts = [node.attr] + value = node.value + while isinstance(value, ast.Attribute): + parts.append(value.attr) + value = value.value + if isinstance(value, ast.Name): + parts.append(value.id) + return ".".join(reversed(parts)) + + +def _base_name(node: ast.expr) -> str: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return node.attr + if isinstance(node, ast.Subscript): + return _base_name(node.value) + return "" + + +def _legacy_module_domain(module: str) -> str | None: + parts = module.split(".") + if len(parts) != 3: + return None + package, domain, name = parts + if package != "avito" or domain not in API_DOMAINS: + return None + if f"{name}.py" not in LEGACY_FILENAMES: + return None + return domain + + +def _path_domain(path: Path, root: Path) -> str | None: + try: + relative = path.relative_to(root) + except ValueError: + return None + parts = relative.parts + if len(parts) < 3 or parts[0] != "avito": + return None + domain = parts[1] + if domain not in API_DOMAINS: + return None + return domain + + +def _iter_python_files(root: Path, directories: Iterable[str]) -> Iterable[Path]: + for path in _iter_text_files(root, directories): + if path.suffix == ".py": + yield path + + +def _iter_text_files(root: Path, directories: Iterable[str]) -> Iterable[Path]: + for directory in directories: + base = root / directory + if not base.exists(): + continue + for path in sorted(base.rglob("*")): + if path.is_file() and path.suffix in TEXT_FILE_SUFFIXES: + yield path + + +def _relative_path(path: Path, root: Path) -> str: + return path.relative_to(root).as_posix() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lint_docstrings.py b/scripts/lint_docstrings.py new file mode 100644 index 0000000..cc4533b --- /dev/null +++ b/scripts/lint_docstrings.py @@ -0,0 +1,95 @@ +"""Static checks for reference-facing SDK docstrings.""" + +from __future__ import annotations + +import argparse +import ast +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path + +GENERIC_DOCSTRING_FRAGMENTS = ( + "Выполняет публичную операцию", + "Пустой результат возвращается", +) + + +@dataclass(frozen=True, slots=True) +class DocstringLintError: + """Single docstring lint violation.""" + + path: str + line: int + message: str + + +def parse_args() -> argparse.Namespace: + """Parse CLI arguments.""" + + parser = argparse.ArgumentParser( + description="Проверить reference-facing docstrings SDK.", + ) + parser.add_argument( + "--root", + type=Path, + default=Path("."), + help="Корень репозитория.", + ) + return parser.parse_args() + + +def main() -> int: + """Run docstring lint CLI.""" + + args = parse_args() + errors = lint_docstrings(args.root) + print(render_report(errors), end="") + return 1 if errors else 0 + + +def lint_docstrings(root: Path = Path(".")) -> tuple[DocstringLintError, ...]: + """Return docstring style violations for repository root.""" + + normalized_root = root.resolve() + errors: list[DocstringLintError] = [] + for path in sorted((normalized_root / "avito").glob("*/domain.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for class_node in (node for node in tree.body if isinstance(node, ast.ClassDef)): + for function_node in ( + node for node in class_node.body if isinstance(node, ast.FunctionDef) + ): + docstring = ast.get_docstring(function_node) or "" + for fragment in GENERIC_DOCSTRING_FRAGMENTS: + if fragment not in docstring: + continue + errors.append( + DocstringLintError( + path=_relative_path(path, normalized_root), + line=function_node.lineno, + message=( + f"`{class_node.name}.{function_node.name}` uses generic " + f"docstring fragment `{fragment}`." + ), + ) + ) + return tuple(errors) + + +def render_report(errors: Sequence[DocstringLintError]) -> str: + """Render human-readable lint report.""" + + lines = [f"Docstring lint: errors={len(errors)}"] + for error in errors: + lines.append(f"{error.path}:{error.line}: {error.message}") + return "\n".join(lines) + "\n" + + +def _relative_path(path: Path, root: Path) -> str: + try: + return str(path.relative_to(root)) + except ValueError: + return str(path) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/contracts/test_client_contracts.py b/tests/contracts/test_client_contracts.py index d602a5a..24f2606 100644 --- a/tests/contracts/test_client_contracts.py +++ b/tests/contracts/test_client_contracts.py @@ -15,7 +15,7 @@ AutotekaVehicle, ) from avito.core import Transport -from avito.core.exceptions import ConfigurationError +from avito.core.exceptions import ClientClosedError from avito.core.types import ApiTimeouts from avito.cpa import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead from avito.jobs import Application, JobDictionary, JobWebhook, Resume, Vacancy @@ -36,6 +36,20 @@ from tests.helpers.transport import make_transport +def make_client_with_transport( + handler: httpx.MockTransport, + *, + user_id: int | None = None, +) -> AvitoClient: + settings = AvitoSettings(auth=AuthSettings(client_id="client-id", client_secret="client-secret")) + auth_provider = AuthProvider(settings.auth) + return AvitoClient._from_transport( + settings, + transport=make_transport(handler, user_id=user_id), + auth_provider=auth_provider, + ) + + def test_single_client_exposes_domain_factories() -> None: client = AvitoClient( AvitoSettings(auth=AuthSettings(client_id="client-id", client_secret="client-secret")) @@ -140,10 +154,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"items": [{"item_id": 101, "amount": 77.5}]}) raise AssertionError(request.url.path) - client = AvitoClient( - AvitoSettings(auth=AuthSettings(client_id="client-id", client_secret="client-secret")) - ) - client.transport = make_transport(httpx.MockTransport(handler), user_id=7) + client = make_client_with_transport(httpx.MockTransport(handler), user_id=7) summary = client.listing_health() @@ -184,10 +195,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) raise AssertionError(request.url.path) - client = AvitoClient( - AvitoSettings(auth=AuthSettings(client_id="client-id", client_secret="client-secret")) - ) - client.transport = make_transport(httpx.MockTransport(handler), user_id=7) + client = make_client_with_transport(httpx.MockTransport(handler), user_id=7) summary = client.listing_health() @@ -225,10 +233,7 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"items": []}) raise AssertionError(request.url.path) - client = AvitoClient( - AvitoSettings(auth=AuthSettings(client_id="client-id", client_secret="client-secret")) - ) - client.transport = make_transport(httpx.MockTransport(handler), user_id=7) + client = make_client_with_transport(httpx.MockTransport(handler), user_id=7) summary = client.listing_health(limit=50, page_size=50) @@ -284,10 +289,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) raise AssertionError(path) - client = AvitoClient( - AvitoSettings(auth=AuthSettings(client_id="client-id", client_secret="client-secret")) - ) - client.transport = make_transport(httpx.MockTransport(handler), user_id=7) + client = make_client_with_transport(httpx.MockTransport(handler), user_id=7) summary = client.business_summary() @@ -337,9 +339,11 @@ def test_debug_info_and_context_manager_do_not_leak_secrets() -> None: alternate_token_client=AlternateTokenClient(settings.auth, client=alternate_http_client), autoteka_token_client=TokenClient(settings.auth, client=autoteka_http_client), ) - client = AvitoClient(settings) - client.transport = Transport(settings, auth_provider=auth_provider, client=transport_http_client) - client.auth_provider = auth_provider + client = AvitoClient._from_transport( + settings, + transport=Transport(settings, auth_provider=auth_provider, client=transport_http_client), + auth_provider=auth_provider, + ) info = client.debug_info() assert info.requires_auth is True @@ -354,7 +358,7 @@ def test_debug_info_and_context_manager_do_not_leak_secrets() -> None: assert autoteka_http_client.is_closed is True -def test_auth_token_clients_use_explicit_sdk_timeouts() -> None: +def test_auth_token_clients_use_explicit_sdk_settings() -> None: settings = AvitoSettings( base_url="https://api.avito.ru", timeouts=ApiTimeouts(connect=2.5, read=11.0, write=13.0, pool=3.0), @@ -362,22 +366,27 @@ def test_auth_token_clients_use_explicit_sdk_timeouts() -> None: ) client = AvitoClient(settings) - token_timeout = client.auth_provider.token_flow().client.timeout - alternate_timeout = client.auth_provider.alternate_token_flow().client.timeout - autoteka_timeout = client.auth_provider.autoteka_token_client.client.timeout - - assert token_timeout.connect == 2.5 - assert token_timeout.read == 11.0 - assert token_timeout.write == 13.0 - assert token_timeout.pool == 3.0 - assert alternate_timeout.connect == 2.5 - assert alternate_timeout.read == 11.0 - assert alternate_timeout.write == 13.0 - assert alternate_timeout.pool == 3.0 - assert autoteka_timeout.connect == 2.5 - assert autoteka_timeout.read == 11.0 - assert autoteka_timeout.write == 13.0 - assert autoteka_timeout.pool == 3.0 + token_settings = client.auth_provider.token_flow().sdk_settings + alternate_settings = client.auth_provider.alternate_token_flow().sdk_settings + autoteka_settings = client.auth_provider.autoteka_token_client.sdk_settings + + assert token_settings is settings + assert alternate_settings is settings + assert autoteka_settings is settings + + client.close() + + +def test_client_core_attributes_are_read_only() -> None: + settings = AvitoSettings(auth=AuthSettings(client_id="client-id", client_secret="client-secret")) + client = AvitoClient(settings) + + with pytest.raises(AttributeError): + client.settings = settings # type: ignore[misc] + with pytest.raises(AttributeError): + client.transport = client.transport # type: ignore[misc] + with pytest.raises(AttributeError): + client.auth_provider = client.auth_provider # type: ignore[misc] client.close() @@ -389,5 +398,5 @@ def test_closed_client_rejects_new_domain_factories() -> None: client.close() - with pytest.raises(ConfigurationError, match="Клиент закрыт"): + with pytest.raises(ClientClosedError, match="Клиент закрыт"): client.account() diff --git a/tests/contracts/test_model_contracts.py b/tests/contracts/test_model_contracts.py index b7159bc..6668c07 100644 --- a/tests/contracts/test_model_contracts.py +++ b/tests/contracts/test_model_contracts.py @@ -136,8 +136,10 @@ def test_examples_and_binary_models_produce_expected_payloads() -> None: ) assert budget_request.to_payload()["campaignType"] == "AS" assert campaigns_request.to_payload()["filter"]["byUpdateTime"]["from"].startswith("2026-04-01") - assert CatalogResolveRequest(brand_id=1).to_payload() == {"brandId": 1} - assert MonitoringBucketRequest(vehicles=["VIN-1"]).to_payload() == {"vehicles": ["VIN-1"]} + assert CatalogResolveRequest(brand_id=1).to_payload() == { + "fieldsValueIds": [{"id": 110000, "valueId": 1}] + } + assert MonitoringBucketRequest(vehicles=["VIN-1"]).to_payload() == {"data": ["VIN-1"]} response = BinaryResponse( content=b"\x00\x01payload", @@ -258,13 +260,24 @@ def handler(request: httpx.Request) -> httpx.Response: profile = Account(transport, user_id=7).get_self() listing = Ad(transport, item_id=101, user_id=7).get() - stats = AdStats(transport, item_id=101, user_id=7).get_item_stats() - calls = AdStats(transport, item_id=101, user_id=7).get_calls_stats() - spendings = AdStats(transport, item_id=101, user_id=7).get_account_spendings() + stats = AdStats(transport, item_id=101, user_id=7).get_item_stats( + date_from="2026-04-01", + date_to="2026-04-02", + ) + calls = AdStats(transport, item_id=101, user_id=7).get_calls_stats( + date_from="2026-04-01", + date_to="2026-04-02", + ) + spendings = AdStats(transport, item_id=101, user_id=7).get_account_spendings( + date_from="2026-04-01", + date_to="2026-04-02", + spending_types=["promotion"], + grouping="day", + ) services = PromotionOrder(transport, order_id="ord-1").list_services(item_ids=[101]) orders = PromotionOrder(transport, order_id="ord-1").list_orders(item_ids=[101]) forecasts = BbipPromotion(transport, item_id=101).get_forecasts( - items=[BbipItem(item_id=101, duration=7, price=1000, old_price=1200).to_dict()] + items=[BbipItem(item_id=101, duration=7, price=1000, old_price=1200)] ) assert isinstance(profile, AccountProfile) diff --git a/tests/contracts/test_public_surface.py b/tests/contracts/test_public_surface.py deleted file mode 100644 index 418d56a..0000000 --- a/tests/contracts/test_public_surface.py +++ /dev/null @@ -1,251 +0,0 @@ -from __future__ import annotations - -import importlib -import inspect -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, - AutotekaScoring, - AutotekaValuation, - AutotekaVehicle, -) -from avito.jobs import Application, JobWebhook, Resume, Vacancy -from avito.messenger import ChatMedia -from avito.orders import DeliveryOrder, Order, OrderLabel, SandboxDelivery, Stock -from avito.realty import RealtyBooking, RealtyListing, RealtyPricing -from avito.testing import FakeResponse, FakeTransport, SwaggerFakeTransport - -MODEL_MODULES = ( - "avito.accounts.models", - "avito.ads.models", - "avito.autoteka.models", - "avito.cpa.models", - "avito.jobs.models", - "avito.messenger.models", - "avito.orders.models", - "avito.promotion.models", - "avito.ratings.models", - "avito.realty.models", - "avito.summary.models", - "avito.tariffs.models", -) - - -def iter_public_dataclasses() -> list[tuple[str, str, type[object]]]: - classes: list[tuple[str, str, type[object]]] = [] - for module_name in MODEL_MODULES: - module = importlib.import_module(module_name) - for name, value in vars(module).items(): - if not inspect.isclass(value) or getattr(value, "__module__", None) != module_name: - continue - if not is_dataclass(value): - continue - classes.append((module_name, name, value)) - return classes - - -def test_removed_generic_request_wrappers_are_not_exported() -> None: - assert "RealtyRequest" not in realty.__all__ - assert "JobsRequest" not in jobs.__all__ - assert "JobsQuery" not in jobs.__all__ - assert "AutotekaRequest" not in autoteka.__all__ - assert "AutotekaQuery" not in autoteka.__all__ - assert "OrdersRequest" not in orders.__all__ - - -def test_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 SwaggerFakeTransport.__module__ == "avito.testing.swagger_fake_transport" - assert FakeResponse.__module__ == "httpx" - - -def test_public_signatures_use_typed_requests_instead_of_generic_wrappers() -> None: - methods = ( - RealtyBooking.update_bookings_info, - RealtyListing.get_intervals, - RealtyPricing.update_realty_prices, - Order.update_markings, - Order.apply, - Order.check_confirmation_code, - OrderLabel.create, - DeliveryOrder.create_announcement, - SandboxDelivery.add_areas, - Stock.update, - Application.apply, - Application.list, - JobWebhook.update, - Resume.list, - Vacancy.create, - Vacancy.update, - AutotekaVehicle.create_preview_by_vin, - AutotekaReport.create_report, - AutotekaMonitoring.get_monitoring_reg_actions, - AutotekaScoring.create_scoring_by_vehicle_id, - AutotekaValuation.get_valuation_by_specification, - ) - banned_tokens = ( - "RealtyRequest", - "JobsRequest", - "JobsQuery", - "AutotekaRequest", - "AutotekaQuery", - "OrdersRequest", - ) - - for method in methods: - public_text = str(inspect.signature(method)) - for token in banned_tokens: - assert token not in public_text - - -def test_public_surface_avoids_raw_dict_signatures_and_legacy_suffixes() -> None: - module_names = ( - "avito.accounts.domain", - "avito.accounts.client", - "avito.ads.domain", - "avito.ads.client", - "avito.autoteka.domain", - "avito.autoteka.client", - "avito.cpa.domain", - "avito.cpa.client", - "avito.jobs.domain", - "avito.jobs.client", - "avito.messenger.domain", - "avito.messenger.client", - "avito.orders.domain", - "avito.orders.client", - "avito.promotion.domain", - "avito.promotion.client", - "avito.ratings.domain", - "avito.ratings.client", - "avito.realty.domain", - "avito.realty.client", - "avito.tariffs.domain", - "avito.tariffs.client", - ) - banned_signature_tokens = ("Mapping[str, object]", "dict[str, object]", "object]") - banned_name_fragments = ("legacy_",) - banned_suffixes = ("_v1", "_v2") - offenders: list[str] = [] - - for module_name in module_names: - module = importlib.import_module(module_name) - for _, cls in inspect.getmembers(module, inspect.isclass): - if cls.__module__ != module_name or cls.__name__.startswith("_"): - continue - for method_name, method in inspect.getmembers(cls, inspect.isfunction): - if method_name.startswith("_"): - continue - signature_text = str(inspect.signature(method)) - if any(token in signature_text for token in banned_signature_tokens): - offenders.append(f"{module_name}.{cls.__name__}.{method_name}") - if any(fragment in method_name for fragment in banned_name_fragments): - offenders.append(f"{module_name}.{cls.__name__}.{method_name}") - if method_name.endswith(banned_suffixes): - offenders.append(f"{module_name}.{cls.__name__}.{method_name}") - - assert offenders == [] - - -def test_public_surface_does_not_raise_valueerror_in_domain_or_client_layers() -> None: - root = Path(__file__).resolve().parents[2] / "avito" - offenders: list[str] = [] - - for path in root.glob("*/domain.py"): - if "raise ValueError" in path.read_text(encoding="utf-8"): - offenders.append(path.as_posix()) - for path in root.glob("*/client.py"): - if "raise ValueError" in path.read_text(encoding="utf-8"): - offenders.append(path.as_posix()) - - assert offenders == [] - - -def test_public_models_do_not_expose_raw_payload_fields() -> None: - offenders = [] - for module_name, name, cls in iter_public_dataclasses(): - if any(field.name in {"raw_payload", "_payload"} for field in fields(cls)): - offenders.append(f"{module_name}:{name}") - - assert offenders == [] - - -def test_chat_media_upload_images_no_longer_accepts_raw_dict() -> None: - signature_text = str(inspect.signature(ChatMedia.upload_images)) - assert "dict[str, object]" not in signature_text - assert "UploadImageFile" in signature_text - - -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/contracts/test_swagger_contracts.py b/tests/contracts/test_swagger_contracts.py index f7a6ce2..3511ed7 100644 --- a/tests/contracts/test_swagger_contracts.py +++ b/tests/contracts/test_swagger_contracts.py @@ -2,6 +2,7 @@ import warnings from collections.abc import Iterator +from typing import cast import pytest @@ -11,21 +12,27 @@ AuthenticationError, AuthorizationError, ConflictError, - NotFoundError, RateLimitError, - ServerError, UpstreamApiError, ValidationError, ) +from avito.core.operations import OperationSpec from avito.core.pagination import PaginatedList from avito.core.swagger_discovery import DiscoveredSwaggerBinding, discover_swagger_bindings +from avito.core.swagger_linter import _load_sdk_method, _operation_specs_for_sdk_method from avito.core.swagger_registry import SwaggerOperation, SwaggerRegistry, load_swagger_registry -from avito.testing import SwaggerFakeTransport, error_payload +from avito.testing import ( + SwaggerFakeTransport, + error_payload, + generate_schema_value, + validate_schema_value, +) _REGISTRY = load_swagger_registry() _DISCOVERY = discover_swagger_bindings(registry=_REGISTRY) _BINDINGS = _DISCOVERY.bindings _BINDING_BY_OPERATION = _DISCOVERY.canonical_map +_BINDING_OPERATION_BY_KEY = {operation.key: operation for operation in _REGISTRY.operations} def _binding_id(binding: DiscoveredSwaggerBinding) -> str: @@ -72,16 +79,12 @@ def _expected_exception_type( return AuthenticationError if status_code == 403: return AuthorizationError - if status_code == 404: - return NotFoundError if status_code == 409: return ConflictError if status_code == 422: return ValidationError if status_code == 429: return RateLimitError - if status_code >= 500: - return ServerError return UpstreamApiError @@ -92,11 +95,57 @@ def _binding(registry: SwaggerRegistry, operation_key: str) -> DiscoveredSwagger return matches[0] +def _operation_spec(binding: DiscoveredSwaggerBinding) -> OperationSpec[object]: + sdk_method = _load_sdk_method(binding) + specs = _operation_specs_for_sdk_method(sdk_method) + assert len(specs) == 1, f"{binding.sdk_method}: expected one OperationSpec, got {len(specs)}" + return cast(OperationSpec[object], specs[0]) + + def test_swagger_contract_coverage_matches_discovered_bindings() -> None: assert len(_BINDINGS) == len(_REGISTRY.operations) == 204 assert len(_BINDING_BY_OPERATION) == len(_REGISTRY.operations) +def test_swagger_operation_specs_cover_all_declared_json_bodies() -> None: + failures: list[str] = [] + for binding in _BINDINGS: + if binding.operation_key is None or binding.domain == "auth": + continue + operation = _BINDING_OPERATION_BY_KEY[binding.operation_key] + spec = _operation_spec(binding) + request_body = operation.request_body + if request_body is not None and "application/json" in request_body.content_types: + if request_body.schema is None: + failures.append(f"{operation.key}: requestBody schema не разобрана") + if spec.request_model is None: + failures.append(f"{operation.key}: {spec.name} без request_model") + for response in operation.success_responses: + if "application/json" not in response.content_types: + continue + if response.schema is None: + failures.append( + f"{operation.key} {response.status_code}: response schema не разобрана" + ) + if spec.response_kind == "json" and spec.response_model is None: + failures.append( + f"{operation.key} {response.status_code}: {spec.name} без response_model" + ) + for response in operation.error_responses: + if "application/json" not in response.content_types: + continue + if response.schema is None: + failures.append( + f"{operation.key} {response.status_code}: error schema не разобрана" + ) + if response.status_code not in spec.error_models: + failures.append( + f"{operation.key} {response.status_code}: {spec.name} без error_model" + ) + + assert failures == [] + + @pytest.mark.parametrize("binding", _BINDINGS, ids=_binding_id) def test_swagger_fake_transport_invokes_every_discovered_binding( binding: DiscoveredSwaggerBinding, @@ -118,6 +167,67 @@ def test_swagger_fake_transport_invokes_every_discovered_binding( assert fake.count() >= 1 +@pytest.mark.parametrize("binding", _BINDINGS, ids=_binding_id) +def test_swagger_fake_transport_request_body_matches_swagger_schema( + binding: DiscoveredSwaggerBinding, +) -> None: + if binding.operation_key is None: + pytest.fail(f"{binding.sdk_method}: binding без operation_key") + operation = _BINDING_OPERATION_BY_KEY[binding.operation_key] + if ( + operation.request_body is None + or "application/json" not in operation.request_body.content_types + ): + return + + fake = SwaggerFakeTransport(registry=_REGISTRY) + fake.add_success_operation(binding.operation_key) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + fake.invoke_binding(binding) + + request = fake.last() + if request.json_body is None: + assert not operation.request_body.required + return + assert operation.request_body.schema is not None + validate_schema_value( + request.json_body, + operation.request_body.schema, + path=f"{operation.key}.requestBody", + ) + + +@pytest.mark.parametrize("binding", _BINDINGS, ids=_binding_id) +def test_swagger_success_response_models_accept_swagger_schema_payload( + binding: DiscoveredSwaggerBinding, +) -> None: + if binding.operation_key is None: + pytest.fail(f"{binding.sdk_method}: binding без operation_key") + operation = _BINDING_OPERATION_BY_KEY[binding.operation_key] + response = next( + ( + item + for item in operation.success_responses + if "application/json" in item.content_types and item.schema is not None + ), + None, + ) + if response is None: + return + + payload = generate_schema_value(response.schema) + validate_schema_value(payload, response.schema, path=f"{operation.key}.{response.status_code}") + fake = SwaggerFakeTransport(registry=_REGISTRY) + fake.add_operation(operation.key, payload, status_code=int(response.status_code)) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + result = fake.invoke_binding(binding) + + assert not isinstance(result, dict) + + def test_swagger_error_contract_coverage_matches_numeric_error_responses() -> None: cases = _error_status_cases() expected_count = sum( @@ -146,6 +256,30 @@ def test_swagger_fake_transport_maps_every_declared_error_status( assert exc_info.value.args[0] == f"Ошибка {status_code}" +@pytest.mark.parametrize("case", _error_status_cases(), ids=_error_status_id) +def test_swagger_error_responses_preserve_swagger_schema_payload( + case: tuple[SwaggerOperation, DiscoveredSwaggerBinding, int, type[Exception]], +) -> None: + operation, binding, status_code, expected_error = case + response = next( + item for item in operation.error_responses if item.status_code == str(status_code) + ) + if "application/json" not in response.content_types: + return + assert response.schema is not None + payload = generate_schema_value(response.schema) + validate_schema_value(payload, response.schema, path=f"{operation.key}.{status_code}") + fake = SwaggerFakeTransport(registry=_REGISTRY) + fake.add_operation(operation.key, payload, status_code=status_code) + + with pytest.raises(expected_error) as exc_info: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + fake.invoke_binding(binding) + + assert exc_info.value.payload == payload + + def test_swagger_deprecated_contract_covers_all_deprecated_operations() -> None: deprecated_bindings = tuple(binding for binding in _BINDINGS if binding.deprecated) @@ -161,7 +295,9 @@ def test_swagger_deprecated_contract_covers_all_deprecated_operations() -> None: def test_swagger_fake_transport_invokes_generated_read_call_and_validates_path() -> None: registry = load_swagger_registry() - binding = _binding(registry, "Информацияопользователе.json GET /core/v1/accounts/{user_id}/balance") + binding = _binding( + registry, "Информацияопользователе.json GET /core/v1/accounts/{user_id}/balance" + ) fake = SwaggerFakeTransport(registry=registry) fake.add_operation( binding.operation_key or "", @@ -195,7 +331,7 @@ def test_swagger_fake_transport_invokes_generated_write_call_and_validates_json_ assert request.method == "POST" assert request.path == "/listItemsByEmployeeIdV1" assert request.headers["content-type"] == "application/json" - assert request.json_body == {"employeeId": 10, "limit": 2, "offset": 0} + assert request.json_body == {"employeeId": 10, "categoryId": 1} assert isinstance(result, PaginatedList) assert isinstance(result[0], EmployeeItem) @@ -254,7 +390,7 @@ def test_swagger_fake_transport_maps_happy_path_response_to_typed_sdk_model() -> ( "АвитоРабота.json GET /job/v1/applications/get_states", 404, - NotFoundError, + UpstreamApiError, ), ( "Автостратегия.json POST /autostrategy/v1/campaign/info", @@ -279,12 +415,12 @@ def test_swagger_fake_transport_maps_happy_path_response_to_typed_sdk_model() -> ( "Информацияопользователе.json GET /core/v1/accounts/self", 500, - ServerError, + UpstreamApiError, ), ( "Информацияопользователе.json GET /core/v1/accounts/self", 503, - ServerError, + UpstreamApiError, ), ], ) diff --git a/tests/core/test_authentication.py b/tests/core/test_authentication.py index c575804..e149f59 100644 --- a/tests/core/test_authentication.py +++ b/tests/core/test_authentication.py @@ -55,6 +55,36 @@ def handler(request: httpx.Request) -> httpx.Response: assert "grant_type=client_credentials" in seen_payloads[0] +def test_token_client_uses_shared_transport_headers_and_timeout() -> None: + captured_user_agents: list[str] = [] + captured_timeouts: list[dict[str, float]] = [] + settings = AvitoSettings( + base_url="https://sandbox.avito.ru", + user_agent_suffix="tests/oauth", + auth=AuthSettings(client_id="client-id", client_secret="client-secret"), + timeouts=ApiTimeouts(connect=1.0, read=2.0, write=3.0, pool=4.0), + ) + + def handler(request: httpx.Request) -> httpx.Response: + captured_user_agents.append(request.headers["User-Agent"]) + captured_timeouts.append(request.extensions["timeout"]) + return httpx.Response(200, json={"access_token": "access-1", "expires_in": 3600}) + + token_client = TokenClient( + settings.auth, + client=make_token_http_client(httpx.MockTransport(handler)), + sdk_settings=settings, + ) + + token_client.request_client_credentials_token( + ClientCredentialsRequest(client_id="client-id", client_secret="client-secret") + ) + + assert captured_user_agents[0].startswith("avito-py/") + assert captured_user_agents[0].endswith("tests/oauth") + assert captured_timeouts == [{"connect": 1.0, "read": 2.0, "write": 3.0, "pool": 4.0}] + + def test_auth_provider_uses_refresh_token_flow_after_initial_token() -> None: issued_access_tokens: Iterator[str] = iter(("access-1", "access-2")) @@ -120,6 +150,10 @@ def test_token_client_maps_authentication_error() -> None: assert error.value.status_code == 401 assert error.value.error_code == "invalid_client" + assert error.value.operation == "auth.oauth_token" + assert error.value.attempt == 1 + assert error.value.method == "POST" + assert error.value.endpoint == "/token" def test_client_auth_surface_exposes_current_token_flows_only() -> None: diff --git a/tests/core/test_client_lifecycle.py b/tests/core/test_client_lifecycle.py new file mode 100644 index 0000000..d94931e --- /dev/null +++ b/tests/core/test_client_lifecycle.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import httpx +import pytest + +from avito import AuthSettings, AvitoClient, AvitoSettings +from avito.auth import AuthProvider +from avito.core import ClientClosedError, Transport + + +def test_closed_client_raises_lifecycle_error_without_http_request() -> None: + calls = {"count": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["count"] += 1 + return httpx.Response(200, json={}) + + settings = AvitoSettings( + auth=AuthSettings(client_id="client-id", client_secret="client-secret") + ) + client = AvitoClient._from_transport( + settings, + transport=Transport( + settings, + auth_provider=None, + client=httpx.Client( + transport=httpx.MockTransport(handler), + base_url="https://api.avito.ru", + ), + ), + auth_provider=AuthProvider(settings.auth), + ) + + client.close() + + with pytest.raises(ClientClosedError, match="Клиент закрыт"): + client.account().get_self() + + assert calls["count"] == 0 diff --git a/tests/core/test_configuration.py b/tests/core/test_configuration.py index 1a6489b..72979f2 100644 --- a/tests/core/test_configuration.py +++ b/tests/core/test_configuration.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from pathlib import Path import pytest @@ -95,7 +96,7 @@ def test_avito_settings_from_env_requires_explicit_auth_values( AvitoSettings.from_env(env_file=write_env_file(tmp_path / ".env", "AVITO_CLIENT_ID=x")) -def test_avito_settings_from_env_accepts_avito_secret_alias( +def test_avito_settings_from_env_accepts_avito_secret_alias_with_deprecation_warning( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: clear_avito_env(monkeypatch) @@ -109,11 +110,40 @@ def test_avito_settings_from_env_accepts_avito_secret_alias( ), ) - settings = AvitoSettings.from_env(env_file=env_file) + with pytest.deprecated_call(match="AVITO_SECRET"): + settings = AvitoSettings.from_env(env_file=env_file) assert settings.auth.client_secret == "legacy-secret" +def test_avito_settings_from_env_prefers_documented_client_secret_without_warning( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + clear_avito_env(monkeypatch) + env_file = write_env_file( + tmp_path / ".env", + "\n".join( + ( + "AVITO_CLIENT_ID=client-id", + "AVITO_CLIENT_SECRET=client-secret", + "AVITO_SECRET=legacy-secret", + ) + ), + ) + + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + settings = AvitoSettings.from_env(env_file=env_file) + + assert settings.auth.client_secret == "client-secret" + + +def test_auth_settings_supported_env_vars_excludes_deprecated_secret_alias() -> None: + supported = AuthSettings.supported_env_vars() + + assert supported["client_secret"] == ("AVITO_CLIENT_SECRET",) + + def test_avito_settings_from_env_ignores_unsupported_generic_aliases( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/tests/core/test_domain.py b/tests/core/test_domain.py new file mode 100644 index 0000000..ad1dbac --- /dev/null +++ b/tests/core/test_domain.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from avito.core import DomainObject, ValidationError +from avito.testing import FakeTransport + + +@dataclass(slots=True, frozen=True) +class ExampleDomain(DomainObject): + """Concrete domain object for core domain tests.""" + + +def test_resolve_user_id_prefers_explicit_value() -> None: + transport = FakeTransport().build(user_id=20) + domain = ExampleDomain(transport) + + assert domain._resolve_user_id("10") == 10 + + +def test_resolve_user_id_uses_configured_user_id() -> None: + transport = FakeTransport().build(user_id=20) + domain = ExampleDomain(transport) + + assert domain._resolve_user_id() == 20 + + +def test_resolve_user_id_falls_back_to_profile_without_legacy_account_client() -> None: + fake_transport = FakeTransport() + fake_transport.add_json("GET", "/core/v1/accounts/self", {"id": 30}) + domain = ExampleDomain(fake_transport.build()) + + assert domain._resolve_user_id() == 30 + assert fake_transport.count(method="GET", path="/core/v1/accounts/self") == 1 + + +def test_resolve_user_id_rejects_profile_without_user_id() -> None: + fake_transport = FakeTransport() + fake_transport.add_json("GET", "/core/v1/accounts/self", {"name": "test"}) + domain = ExampleDomain(fake_transport.build()) + + with pytest.raises(ValidationError, match="user_id"): + domain._resolve_user_id() diff --git a/tests/core/test_models.py b/tests/core/test_models.py new file mode 100644 index 0000000..5f5f4c8 --- /dev/null +++ b/tests/core/test_models.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date, datetime +from enum import StrEnum + +from avito.core import RequestModel, api_field + + +class ExampleMode(StrEnum): + ACTIVE = "active" + + +@dataclass(slots=True, frozen=True) +class NestedRequest(RequestModel): + city_id: int = api_field("cityId") + + +@dataclass(slots=True, frozen=True) +class ExampleRequest(RequestModel): + item_id: int = api_field("itemId") + created_at: datetime = api_field("createdAt") + day: date = api_field("day") + mode: ExampleMode = ExampleMode.ACTIVE + nested: NestedRequest = field(default_factory=lambda: NestedRequest(city_id=1)) + tags: list[ExampleMode | None] = field(default_factory=lambda: [ExampleMode.ACTIVE, None]) + skipped: str | None = None + raw_payload: dict[str, object] | None = None + + +def test_request_model_to_payload_uses_api_field_names_and_serializes_values() -> None: + request = ExampleRequest( + item_id=42, + created_at=datetime(2026, 4, 30, 12, 15), + day=date(2026, 4, 30), + raw_payload={"secret": True}, + ) + + assert request.to_payload() == { + "itemId": 42, + "mode": "active", + "createdAt": "2026-04-30T12:15:00", + "day": "2026-04-30", + "nested": {"cityId": 1}, + "tags": ["active"], + } + + +def test_request_model_to_params_matches_payload_contract() -> None: + request = NestedRequest(city_id=10) + + assert request.to_params() == {"cityId": 10} diff --git a/tests/core/test_operations.py b/tests/core/test_operations.py new file mode 100644 index 0000000..b5eaf33 --- /dev/null +++ b/tests/core/test_operations.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import httpx +import pytest + +from avito.core import ( + ApiModel, + ApiTimeouts, + BinaryResponse, + EmptyResponse, + OperationExecutor, + OperationSpec, + RequestModel, + api_field, +) +from avito.core.operations import render_path +from avito.core.types import RequestContext +from avito.testing import FakeTransport + + +def test_render_path_encodes_path_params() -> None: + assert render_path("/items/{item_id}", {"item_id": "a/b"}) == "/items/a%2Fb" + + +@dataclass(slots=True, frozen=True) +class ItemQuery(RequestModel): + page: int + item_id: str = api_field("itemId") + + +@dataclass(slots=True, frozen=True) +class ItemRequest(RequestModel): + title: str + price: int | None = None + + +@dataclass(slots=True, frozen=True) +class ItemResponse(ApiModel): + item_id: str + title: str + + @classmethod + def from_payload(cls, payload: object) -> ItemResponse: + if not isinstance(payload, dict): + raise AssertionError("expected payload mapping") + return cls(item_id=str(payload["id"]), title=str(payload["title"])) + + +class RecordingOperationTransport: + def __init__(self) -> None: + self.contexts: list[RequestContext] = [] + + def request( + self, + method: str, + path: str, + *, + context: RequestContext, + **_kwargs: object, + ) -> httpx.Response: + self.contexts.append(context) + return httpx.Response(204) + + def request_json( + self, + method: str, + path: str, + *, + context: RequestContext, + **_kwargs: object, + ) -> object: + self.contexts.append(context) + return {"id": "item-1", "title": "new"} + + +def test_operation_executor_serializes_path_query_request_and_response_model() -> None: + fake_transport = FakeTransport() + fake_transport.add_json("POST", "/items/a/b", {"id": "a/b", "title": "new"}) + transport = fake_transport.build() + spec = OperationSpec( + name="items.create", + method="POST", + path="/items/{item_id}", + query_model=ItemQuery, + request_model=ItemRequest, + response_model=ItemResponse, + retry_mode="enabled", + ) + + result = OperationExecutor(transport).execute( + spec, + path_params={"item_id": "a/b"}, + query=ItemQuery(page=2, item_id="a/b"), + request=ItemRequest(title="new"), + headers={"X-Test": "yes"}, + idempotency_key="key-1", + ) + + assert result == ItemResponse(item_id="a/b", title="new") + request = fake_transport.last(method="POST", path="/items/a/b") + assert request.params == {"page": "2", "itemId": "a/b"} + assert request.json_body == {"title": "new"} + assert request.headers["x-test"] == "yes" + assert request.headers["idempotency-key"] == "key-1" + + +def test_operation_executor_empty_response_does_not_read_json() -> None: + fake_transport = FakeTransport() + fake_transport.add("DELETE", "/items/1", FakeTransportResponse.empty()) + transport = fake_transport.build() + spec = OperationSpec( + name="items.delete", + method="DELETE", + path="/items/{item_id}", + response_kind="empty", + ) + + result = OperationExecutor(transport).execute(spec, path_params={"item_id": 1}) + + assert result == EmptyResponse(status_code=204, headers={}) + + +def test_operation_executor_binary_response_uses_transport_request() -> None: + fake_transport = FakeTransport() + fake_transport.add( + "GET", + "/items/1/file", + FakeTransportResponse.binary(b"file", content_type="application/pdf"), + ) + transport = fake_transport.build() + spec = OperationSpec( + name="items.file", + method="GET", + path="/items/{item_id}/file", + response_kind="binary", + ) + + result = OperationExecutor(transport).execute(spec, path_params={"item_id": 1}) + + assert isinstance(result, BinaryResponse) + assert result.content == b"file" + assert result.content_type == "application/pdf" + + +def test_operation_executor_passes_timeout_to_request_context() -> None: + transport = RecordingOperationTransport() + timeouts = ApiTimeouts(connect=1.0, read=2.0, write=3.0, pool=4.0) + spec = OperationSpec( + name="items.get", + method="GET", + path="/items/1", + response_model=ItemResponse, + ) + + OperationExecutor(transport).execute(spec, timeout=timeouts) + + assert transport.contexts[-1].timeout == timeouts + + +@pytest.mark.parametrize( + ("spec_retry", "override", "allow_retry", "retry_disabled"), + ( + ("default", None, False, False), + ("enabled", None, True, False), + ("disabled", None, False, True), + ("disabled", "default", False, True), + ("disabled", "enabled", True, False), + ("enabled", "disabled", False, True), + ), +) +def test_operation_executor_resolves_retry_override_precedence( + spec_retry: str, + override: str | None, + allow_retry: bool, + retry_disabled: bool, +) -> None: + transport = RecordingOperationTransport() + spec = OperationSpec( + name="items.get", + method="GET", + path="/items/1", + response_model=ItemResponse, + retry_mode=spec_retry, + ) + + OperationExecutor(transport).execute(spec, retry=override) + + context = transport.contexts[-1] + assert context.allow_retry is allow_retry + assert context.retry_disabled is retry_disabled + + +class FakeTransportResponse: + @staticmethod + def empty() -> object: + import httpx + + return httpx.Response(204) + + @staticmethod + def binary(content: bytes, *, content_type: str) -> object: + import httpx + + return httpx.Response(200, content=content, headers={"content-type": content_type}) diff --git a/tests/core/test_payload.py b/tests/core/test_payload.py new file mode 100644 index 0000000..d7cbf36 --- /dev/null +++ b/tests/core/test_payload.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from enum import StrEnum + +import pytest + +from avito.core import JsonReader, ResponseMappingError + + +class Status(StrEnum): + ACTIVE = "active" + UNKNOWN = "unknown" + + +def test_json_reader_reads_typed_values_and_fallback_keys() -> None: + reader = JsonReader( + { + "id": 12, + "title": "item", + "price": 10, + "enabled": True, + "createdAt": "2026-04-30T09:00:00Z", + "status": "active", + "nested": {"value": 1}, + "items": [1, 2], + } + ) + + assert reader.required_int("missing", "id") == 12 + assert reader.required_str("title") == "item" + assert reader.required_float("price") == 10.0 + assert reader.required_bool("enabled") is True + assert reader.required_datetime("createdAt") == datetime( + 2026, 4, 30, 9, 0, tzinfo=UTC + ) + assert reader.enum(Status, "status") == Status.ACTIVE + assert reader.mapping("nested") == {"value": 1} + assert reader.list("items") == [1, 2] + + +def test_json_reader_rejects_bool_as_int() -> None: + reader = JsonReader({"id": True}) + + with pytest.raises(ResponseMappingError, match="целое число"): + reader.required_int("id") + + +def test_json_reader_uses_unknown_enum_fallback() -> None: + reader = JsonReader({"status": "new-value"}) + + assert reader.enum(Status, "status", unknown=Status.UNKNOWN) == Status.UNKNOWN + + +def test_json_reader_reports_missing_required_field() -> None: + reader = JsonReader({}) + + with pytest.raises(ResponseMappingError, match="обязательное поле"): + reader.required_str("name") + + +def test_json_reader_rejects_invalid_payload_type() -> None: + with pytest.raises(ResponseMappingError, match="JSON-объект"): + JsonReader("bad") diff --git a/tests/core/test_swagger.py b/tests/core/test_swagger.py deleted file mode 100644 index 9bfb453..0000000 --- a/tests/core/test_swagger.py +++ /dev/null @@ -1,114 +0,0 @@ -from __future__ import annotations - -import builtins -import importlib -from collections.abc import Iterator -from contextlib import contextmanager -from typing import cast - -import pytest - -import avito.core.swagger -from avito.core import ConfigurationError, SwaggerOperationBinding, swagger_operation - - -@contextmanager -def _forbid_swagger_file_reads() -> Iterator[None]: - original_open = builtins.open - - def guarded_open(file: object, *args: object, **kwargs: object) -> object: - if "docs/avito/api" in str(file): - raise AssertionError("Swagger files must not be read on import") - return original_open(file, *args, **kwargs) - - builtins.open = guarded_open - try: - yield - finally: - builtins.open = original_open - - -def test_swagger_operation_writes_metadata_to_decorated_method() -> None: - @swagger_operation( - "get", - "/messenger/v1/accounts/{user_id}/chats/", - spec="Мессенджер.json", - operation_id="getChats", - factory="chat", - factory_args={"user_id": "path.user_id"}, - method_args={"limit": "query.limit"}, - deprecated=True, - legacy=True, - ) - def list_chats() -> str: - return "ok" - - binding = cast(SwaggerOperationBinding, list_chats.__swagger_binding__) - - assert binding == SwaggerOperationBinding( - method="GET", - path="/messenger/v1/accounts/{user_id}/chats", - spec="Мессенджер.json", - operation_id="getChats", - factory="chat", - factory_args={"user_id": "path.user_id"}, - method_args={"limit": "query.limit"}, - deprecated=True, - legacy=True, - ) - - -def test_swagger_operation_does_not_change_decorated_method_behavior() -> None: - @swagger_operation("POST", "/items/{item_id}") - def update_item(item_id: int, *, title: str) -> tuple[int, str]: - return item_id, title - - assert update_item(42, title="listing") == (42, "listing") - - -def test_swagger_operation_stores_immutable_mapping_copies() -> None: - factory_args = {"user_id": "path.user_id"} - method_args = {"limit": "query.limit"} - - @swagger_operation( - "GET", - "/items", - factory_args=factory_args, - method_args=method_args, - ) - def list_items() -> str: - return "ok" - - factory_args["user_id"] = "query.user_id" - method_args["limit"] = "constant.limit" - binding = cast(SwaggerOperationBinding, list_items.__swagger_binding__) - - assert binding.factory_args["user_id"] == "path.user_id" - assert binding.method_args["limit"] == "query.limit" - with pytest.raises(TypeError): - cast(dict[str, str], binding.factory_args)["extra"] = "query.extra" - with pytest.raises(TypeError): - cast(dict[str, str], binding.method_args)["extra"] = "query.extra" - - -def test_swagger_operation_rejects_forbidden_kwargs_by_signature() -> None: - with pytest.raises(TypeError): - swagger_operation( - "GET", - "/items", - response_model="Forbidden", - ) - - -def test_swagger_operation_rejects_stacked_bindings() -> None: - with pytest.raises(ConfigurationError): - - @swagger_operation("GET", "/items") - @swagger_operation("POST", "/items") - def sync_items() -> str: - return "ok" - - -def test_swagger_module_does_not_read_swagger_files_on_import() -> None: - with _forbid_swagger_file_reads(): - importlib.reload(avito.core.swagger) diff --git a/tests/core/test_swagger_discovery.py b/tests/core/test_swagger_discovery.py deleted file mode 100644 index a85bd63..0000000 --- a/tests/core/test_swagger_discovery.py +++ /dev/null @@ -1,135 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator -from contextlib import contextmanager - -from avito.accounts.domain import Account -from avito.client import AvitoClient -from avito.config import AvitoSettings -from avito.core.swagger import SwaggerOperationBinding -from avito.core.swagger_discovery import discover_swagger_bindings -from avito.core.swagger_registry import load_swagger_registry - - -@contextmanager -def _temporary_account_binding( - binding: SwaggerOperationBinding, -) -> Iterator[None]: - original_binding = getattr(Account.get_self, "__swagger_binding__", None) - original_domain = getattr(Account, "__swagger_domain__", None) - original_spec = getattr(Account, "__swagger_spec__", None) - original_factory = getattr(Account, "__sdk_factory__", None) - original_factory_args = getattr(Account, "__sdk_factory_args__", None) - Account.get_self.__swagger_binding__ = binding # type: ignore[attr-defined] - Account.__swagger_domain__ = "accounts" - Account.__swagger_spec__ = "Информацияопользователе.json" - Account.__sdk_factory__ = "account" - Account.__sdk_factory_args__ = {"user_id": "constant.user_id"} - try: - yield - finally: - if original_binding is None: - delattr(Account.get_self, "__swagger_binding__") - else: - Account.get_self.__swagger_binding__ = original_binding # type: ignore[attr-defined] - _restore_class_attribute(Account, "__swagger_domain__", original_domain) - _restore_class_attribute(Account, "__swagger_spec__", original_spec) - _restore_class_attribute(Account, "__sdk_factory__", original_factory) - _restore_class_attribute(Account, "__sdk_factory_args__", original_factory_args) - - -def _restore_class_attribute(cls: type[object], name: str, value: object) -> None: - if value is None: - if hasattr(cls, name): - delattr(cls, name) - return - setattr(cls, name, value) - - -def test_discover_swagger_bindings_returns_empty_result_for_unmarked_sdk() -> None: - discovery = discover_swagger_bindings() - - assert len(discovery.bindings) == 204 - assert len(discovery.canonical_map) == 204 - - -def test_discover_swagger_bindings_does_not_create_client_or_read_env( - monkeypatch, -) -> None: - def fail_init(self: AvitoClient, *args: object, **kwargs: object) -> None: - raise AssertionError("AvitoClient must not be created during discovery") - - def fail_from_env() -> AvitoSettings: - raise AssertionError("Environment settings must not be read during discovery") - - monkeypatch.setattr(AvitoClient, "__init__", fail_init) - monkeypatch.setattr(AvitoSettings, "from_env", fail_from_env) - - discovery = discover_swagger_bindings() - - assert len(discovery.bindings) == 204 - - -def test_discover_swagger_bindings_uses_class_level_defaults() -> None: - binding = SwaggerOperationBinding( - method="get", - path="/core/v1/accounts/self", - operation_id="getUserInfoSelf", - ) - - with _temporary_account_binding(binding): - discovery = discover_swagger_bindings() - - discovered = _find_binding(discovery.bindings, "avito.accounts.domain.Account.get_self") - assert discovered.sdk_method == "avito.accounts.domain.Account.get_self" - assert discovered.domain == "accounts" - assert discovered.operation_key == "Информацияопользователе.json GET /core/v1/accounts/self" - assert discovered.factory == "account" - assert dict(discovered.factory_args) == {"user_id": "constant.user_id"} - assert discovered.operation_id == "getUserInfoSelf" - - -def test_discover_swagger_bindings_auto_resolves_spec_from_registry() -> None: - binding = SwaggerOperationBinding( - method="GET", - path="/core/v1/accounts/self", - operation_id="getUserInfoSelf", - ) - registry = load_swagger_registry() - - with _temporary_account_binding(binding): - delattr(Account, "__swagger_spec__") - discovery = discover_swagger_bindings(registry=registry) - - discovered = _find_binding(discovery.bindings, "avito.accounts.domain.Account.get_self") - assert discovered.spec == "Информацияопользователе.json" - assert discovered.operation_key == "Информацияопользователе.json GET /core/v1/accounts/self" - assert discovery.canonical_map[discovered.operation_key] == discovered - - -def test_discover_swagger_bindings_reports_legacy_stacked_metadata() -> None: - original_binding = getattr(Account.get_self, "__swagger_binding__", None) - original_bindings = getattr(Account.get_self, "__swagger_bindings__", None) - binding = SwaggerOperationBinding(method="GET", path="/core/v1/accounts/self") - Account.get_self.__swagger_binding__ = binding # type: ignore[attr-defined] - Account.get_self.__swagger_bindings__ = (binding,) # type: ignore[attr-defined] - try: - discovery = discover_swagger_bindings() - finally: - if original_binding is None: - delattr(Account.get_self, "__swagger_binding__") - else: - Account.get_self.__swagger_binding__ = original_binding # type: ignore[attr-defined] - if original_bindings is None: - delattr(Account.get_self, "__swagger_bindings__") - else: - Account.get_self.__swagger_bindings__ = original_bindings # type: ignore[attr-defined] - - assert "avito.accounts.domain.Account.get_self" in discovery.legacy_binding_methods - - -def _find_binding(bindings: object, sdk_method: str) -> object: - for binding in bindings: - if binding.sdk_method == sdk_method: - return binding - raise AssertionError(f"Binding not found: {sdk_method}") diff --git a/tests/core/test_swagger_factory_map.py b/tests/core/test_swagger_factory_map.py deleted file mode 100644 index c20974c..0000000 --- a/tests/core/test_swagger_factory_map.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -from avito.client import AvitoClient -from avito.config import AvitoSettings -from avito.core.swagger_discovery import discover_swagger_bindings -from avito.core.swagger_factory_map import build_factory_domain_mapping_report -from avito.core.swagger_registry import load_swagger_registry -from avito.core.swagger_report import build_swagger_binding_report - - -def test_build_factory_domain_mapping_report_does_not_create_client_or_read_env( - monkeypatch, -) -> None: - def fail_init(self: AvitoClient, *args: object, **kwargs: object) -> None: - raise AssertionError("AvitoClient must not be created during factory mapping") - - def fail_from_env() -> AvitoSettings: - raise AssertionError("Environment settings must not be read during factory mapping") - - monkeypatch.setattr(AvitoClient, "__init__", fail_init) - monkeypatch.setattr(AvitoSettings, "from_env", fail_from_env) - - report = build_factory_domain_mapping_report() - - assert report.factories - - -def test_build_factory_domain_mapping_report_maps_factories_to_domain_classes() -> None: - report = build_factory_domain_mapping_report() - factories = {mapping.factory: mapping for mapping in report.factories} - - assert factories["account"].domain_class == "Account" - assert factories["account"].module == "avito.accounts.domain" - assert factories["account"].factory_args == ("user_id",) - assert factories["account"].spec_candidates == ("Информацияопользователе.json",) - assert factories["chat"].domain_class == "Chat" - assert factories["chat"].factory_args == ("chat_id", "user_id") - assert factories["chat"].spec_candidates == ("Мессенджер.json",) - assert factories["promotion_order"].spec_candidates == ("Продвижение.json",) - - -def test_build_factory_domain_mapping_report_identifies_summary_and_helper_methods() -> None: - report = build_factory_domain_mapping_report() - helper_methods = {helper.method: helper for helper in report.helper_methods} - - assert helper_methods["account_health"].reason == ( - "summary/helper method; no direct upstream Swagger operation" - ) - assert helper_methods["business_summary"].reason == ( - "summary/helper method; no direct upstream Swagger operation" - ) - assert helper_methods["capabilities"].reason == ( - "summary/helper method; no direct upstream Swagger operation" - ) - assert "account" not in helper_methods - - -def test_swagger_binding_report_includes_factory_mapping_as_non_authoritative_section() -> None: - registry = load_swagger_registry() - discovery = discover_swagger_bindings(registry=registry) - factory_mapping = build_factory_domain_mapping_report() - - report = build_swagger_binding_report( - registry, - discovery, - factory_mapping=factory_mapping, - ).to_dict() - - assert report["summary"]["operations_total"] == 204 - assert report["factory_mapping"] == factory_mapping.to_dict() diff --git a/tests/core/test_swagger_linter.py b/tests/core/test_swagger_linter.py index bcbd084..59c6681 100644 --- a/tests/core/test_swagger_linter.py +++ b/tests/core/test_swagger_linter.py @@ -1,543 +1,74 @@ -from __future__ import annotations +"""Tests for Swagger binding linter rules.""" -from collections.abc import Iterator -from contextlib import contextmanager +from __future__ import annotations -from avito.accounts.domain import Account, AccountHierarchy -from avito.ads.domain import AutoloadArchive -from avito.core.swagger import SwaggerOperationBinding -from avito.core.swagger_discovery import ( - DiscoveredSwaggerBinding, - SwaggerBindingDiscovery, - discover_swagger_bindings, -) -from avito.core.swagger_linter import lint_swagger_bindings +from avito.core.operations import OperationSpec +from avito.core.swagger_discovery import DiscoveredSwaggerBinding +from avito.core.swagger_linter import _validate_operation_json_body_models from avito.core.swagger_registry import ( SwaggerOperation, - SwaggerRegistry, SwaggerRequestBody, SwaggerResponse, - SwaggerSpec, - load_swagger_registry, + SwaggerSchema, ) -@contextmanager -def _temporary_binding( - cls: type[object], - method_name: str, - binding: SwaggerOperationBinding, - *, - spec: str | None = "Информацияопользователе.json", - factory: str | None = "account", -) -> Iterator[None]: - method = getattr(cls, method_name) - original_binding = getattr(method, "__swagger_binding__", None) - original_domain = getattr(cls, "__swagger_domain__", None) - original_spec = getattr(cls, "__swagger_spec__", None) - original_factory = getattr(cls, "__sdk_factory__", None) - method.__swagger_binding__ = binding - cls.__swagger_domain__ = "accounts" - _set_optional_class_attribute(cls, "__swagger_spec__", spec) - _set_optional_class_attribute(cls, "__sdk_factory__", factory) - try: - yield - finally: - if original_binding is None: - delattr(method, "__swagger_binding__") - else: - method.__swagger_binding__ = original_binding - _restore_class_attribute(cls, "__swagger_domain__", original_domain) - _restore_class_attribute(cls, "__swagger_spec__", original_spec) - _restore_class_attribute(cls, "__sdk_factory__", original_factory) - - -@contextmanager -def _temporary_account_bindings( - bindings: dict[str, SwaggerOperationBinding], -) -> Iterator[None]: - original_bindings = { - method_name: getattr(getattr(Account, method_name), "__swagger_binding__", None) - for method_name in bindings - } - original_domain = getattr(Account, "__swagger_domain__", None) - original_spec = getattr(Account, "__swagger_spec__", None) - original_factory = getattr(Account, "__sdk_factory__", None) - Account.__swagger_domain__ = "accounts" - Account.__swagger_spec__ = "Информацияопользователе.json" - Account.__sdk_factory__ = "account" - for method_name, binding in bindings.items(): - getattr(Account, method_name).__swagger_binding__ = binding - try: - yield - finally: - for method_name, original_binding in original_bindings.items(): - method = getattr(Account, method_name) - if original_binding is None: - delattr(method, "__swagger_binding__") - else: - method.__swagger_binding__ = original_binding - _restore_class_attribute(Account, "__swagger_domain__", original_domain) - _restore_class_attribute(Account, "__swagger_spec__", original_spec) - _restore_class_attribute(Account, "__sdk_factory__", original_factory) - - -def _set_optional_class_attribute(cls: type[object], name: str, value: str | None) -> None: - if value is None: - if hasattr(cls, name): - delattr(cls, name) - return - setattr(cls, name, value) - - -def _restore_class_attribute(cls: type[object], name: str, value: object) -> None: - if value is None: - if hasattr(cls, name): - delattr(cls, name) - return - setattr(cls, name, value) - - -def test_lint_swagger_bindings_allows_empty_discovery_in_non_strict_mode() -> None: - registry = load_swagger_registry() - discovery = discover_swagger_bindings(registry=registry) - - errors = lint_swagger_bindings(registry, discovery) - - assert errors == () - - -def test_lint_swagger_bindings_rejects_unknown_spec() -> None: - registry = load_swagger_registry() - binding = SwaggerOperationBinding(method="GET", path="/core/v1/accounts/self") - - with _temporary_binding(Account, "get_self", binding, spec="НетТакогоSpec.json"): - discovery = discover_swagger_bindings(registry=registry) - - errors = lint_swagger_bindings(registry, discovery) - - assert [error.code for error in errors] == ["SWAGGER_BINDING_SPEC_NOT_FOUND"] - - -def test_lint_swagger_bindings_rejects_missing_operation() -> None: - registry = load_swagger_registry() - binding = SwaggerOperationBinding(method="GET", path="/missing") - - with _temporary_binding(Account, "get_self", binding): - discovery = discover_swagger_bindings(registry=registry) - - errors = lint_swagger_bindings(registry, discovery) - - assert [error.code for error in errors] == ["SWAGGER_BINDING_NOT_FOUND"] - - -def test_lint_swagger_bindings_rejects_duplicate_operation_bindings() -> None: - registry = load_swagger_registry() - binding = SwaggerOperationBinding(method="GET", path="/core/v1/accounts/self") - - with _temporary_account_bindings({"get_self": binding, "get_balance": binding}): - discovery = discover_swagger_bindings(registry=registry) - - errors = lint_swagger_bindings(registry, discovery) - - assert [error.code for error in errors] == [ - "SWAGGER_BINDING_DUPLICATE", - "SWAGGER_BINDING_DUPLICATE", - ] - - -def test_lint_swagger_bindings_rejects_one_sdk_method_bound_to_multiple_operations() -> None: - registry = load_swagger_registry() - discovery = discover_swagger_bindings(registry=registry) - first = discovery.bindings[0] - duplicate_sdk_method = type(first)( - module=first.module, - class_name=first.class_name, - method_name=first.method_name, - domain=first.domain, - operation_key="Информацияопользователе.json GET /core/v1/accounts/{user_id}/balance", - spec="Информацияопользователе.json", - method="GET", - path="/core/v1/accounts/{user_id}/balance", - operation_id="getUserBalance", - factory=first.factory, - factory_args=first.factory_args, - method_args=first.method_args, - deprecated=first.deprecated, - legacy=first.legacy, - ) - patched_discovery = type(discovery)( - bindings=(first, duplicate_sdk_method), - legacy_binding_methods=(), - ) - - errors = lint_swagger_bindings(registry, patched_discovery) - - assert _codes_for(errors, first.sdk_method, exclude={"SWAGGER_BINDING_DUPLICATE"}) == [ - "SWAGGER_BINDING_METHOD_MULTIPLE", - "SWAGGER_BINDING_METHOD_MULTIPLE", - ] - - -def test_lint_swagger_bindings_rejects_legacy_stacked_metadata() -> None: - registry = load_swagger_registry() - discovery = type(discover_swagger_bindings(registry=registry))( - bindings=(), - legacy_binding_methods=("avito.accounts.domain.Account.get_self",), - ) - - errors = lint_swagger_bindings(registry, discovery) - - assert _codes_for(errors, "avito.accounts.domain.Account.get_self") == [ - "SWAGGER_BINDING_METHOD_MULTIPLE" - ] - - -def test_lint_swagger_bindings_rejects_metadata_mismatches() -> None: - registry = load_swagger_registry() - binding = SwaggerOperationBinding( - method="GET", - path="/core/v1/accounts/self", - operation_id="wrongOperationId", - deprecated=True, - legacy=True, - ) - - with _temporary_binding(Account, "get_self", binding): - discovery = discover_swagger_bindings(registry=registry) - - errors = lint_swagger_bindings(registry, discovery) - - assert [error.code for error in errors] == [ - "SWAGGER_BINDING_OPERATION_ID_MISMATCH", - "SWAGGER_BINDING_DEPRECATED_MISMATCH", - "SWAGGER_BINDING_LEGACY_MISMATCH", - ] - - -def test_lint_swagger_bindings_rejects_missing_and_unknown_factory() -> None: - registry = load_swagger_registry() - binding = SwaggerOperationBinding(method="GET", path="/core/v1/accounts/self") - - with _temporary_binding(Account, "get_self", binding, factory=None): - discovery = discover_swagger_bindings(registry=registry) - missing_errors = lint_swagger_bindings(registry, discovery) - - with _temporary_binding(Account, "get_self", binding, factory="missing_factory"): - discovery = discover_swagger_bindings(registry=registry) - unknown_errors = lint_swagger_bindings(registry, discovery) - - assert _codes_for(missing_errors, "avito.accounts.domain.Account.get_self") == [ - "SWAGGER_BINDING_FACTORY_MISSING" - ] - assert _codes_for(unknown_errors, "avito.accounts.domain.Account.get_self") == [ - "SWAGGER_BINDING_FACTORY_NOT_FOUND" - ] - - -def test_lint_swagger_bindings_validates_factory_and_method_signatures() -> None: - registry = load_swagger_registry() - binding = SwaggerOperationBinding( +def test_validate_operation_json_body_models_requires_declared_models() -> None: + schema = SwaggerSchema(kind="object") + binding = DiscoveredSwaggerBinding( + module="avito.accounts.domain", + class_name="Account", + method_name="example", + domain="accounts", + operation_key="Spec.json POST /example", + spec="Spec.json", method="POST", - path="/linkItemsV1", - spec="ИерархияАккаунтов.json", - factory_args={"unknown": "constant.value"}, - method_args={"employee_id": "body.employee_id", "unknown": "constant.value"}, + path="/example", + operation_id="example", + factory="account", ) - - with _temporary_binding( - AccountHierarchy, - "link_items", - binding, - spec=None, - factory="account_hierarchy", - ): - discovery = discover_swagger_bindings(registry=registry) - - errors = lint_swagger_bindings(registry, discovery) - - assert _codes_for( - errors, - "avito.accounts.domain.AccountHierarchy.link_items", - exclude={"SWAGGER_BINDING_DUPLICATE"}, - ) == [ - "SWAGGER_BINDING_FACTORY_ARG_UNKNOWN", - "SWAGGER_BINDING_METHOD_ARG_UNKNOWN", - "SWAGGER_BINDING_METHOD_ARG_REQUIRED", - ] - - -def test_lint_swagger_bindings_validates_parameter_expressions_against_swagger() -> None: - registry = load_swagger_registry() - cases = { - "path": ( - SwaggerOperationBinding( - method="GET", - path="/core/v1/accounts/{user_id}/balance", - method_args={"user_id": "path.missing"}, - ), - "SWAGGER_BINDING_PATH_PARAMETER_NOT_FOUND", - ), - "query": ( - SwaggerOperationBinding( - method="GET", - path="/core/v1/accounts/{user_id}/balance", - method_args={"user_id": "query.missing"}, - ), - "SWAGGER_BINDING_QUERY_PARAMETER_NOT_FOUND", - ), - "header": ( - SwaggerOperationBinding( - method="GET", - path="/core/v1/accounts/{user_id}/balance", - method_args={"user_id": "header.missing"}, - ), - "SWAGGER_BINDING_HEADER_PARAMETER_NOT_FOUND", - ), - } - - for case_name, (binding, expected_code) in cases.items(): - with _temporary_binding(Account, "get_balance", binding): - discovery = discover_swagger_bindings(registry=registry) - - errors = lint_swagger_bindings(registry, discovery) - - assert _codes_for( - errors, - "avito.accounts.domain.Account.get_balance", - exclude={"SWAGGER_BINDING_DUPLICATE"}, - ) == [expected_code], case_name - - -def test_lint_swagger_bindings_validates_body_and_constant_expressions() -> None: - registry = load_swagger_registry() - cases = { - "body": ( - SwaggerOperationBinding( - method="GET", - path="/core/v1/accounts/{user_id}/balance", - method_args={"user_id": "body.user_id"}, - ), - "SWAGGER_BINDING_BODY_MISSING", - ), - "constant": ( - SwaggerOperationBinding( - method="GET", - path="/core/v1/accounts/{user_id}/balance", - method_args={"user_id": "constant.missing"}, - ), - "SWAGGER_BINDING_CONSTANT_NOT_FOUND", - ), - "unknown_prefix": ( - SwaggerOperationBinding( - method="GET", - path="/core/v1/accounts/{user_id}/balance", - method_args={"user_id": "cookie.user_id"}, - ), - "SWAGGER_BINDING_EXPRESSION_UNKNOWN", - ), - } - - for case_name, (binding, expected_code) in cases.items(): - with _temporary_binding(Account, "get_balance", binding): - discovery = discover_swagger_bindings(registry=registry) - - errors = lint_swagger_bindings(registry, discovery) - - assert _codes_for( - errors, - "avito.accounts.domain.Account.get_balance", - exclude={"SWAGGER_BINDING_DUPLICATE"}, - ) == [expected_code], case_name - - -def test_lint_swagger_bindings_allows_valid_body_and_constant_expressions() -> None: - registry = load_swagger_registry() - binding = SwaggerOperationBinding( + operation = SwaggerOperation( + spec="Spec.json", method="POST", - path="/core/v1/accounts/operations_history", - method_args={"date_from": "body.date_time_from"}, - factory_args={"user_id": "constant.user_id"}, - ) - - with _temporary_binding(Account, "get_operations_history", binding): - discovery = discover_swagger_bindings(registry=registry) - - errors = lint_swagger_bindings(registry, discovery) - - assert _codes_for( - errors, - "avito.accounts.domain.Account.get_operations_history", - exclude={"SWAGGER_BINDING_DUPLICATE"}, - ) == [] - - -def test_lint_swagger_bindings_rejects_unknown_body_field() -> None: - registry, discovery = _single_body_field_discovery( - expression="body.missing", - request_body=SwaggerRequestBody( - required=True, - content_types=("application/json",), - field_names=("employeeId", "employee_id"), - schema_extracted=True, - ), - ) - - errors = lint_swagger_bindings(registry, discovery) - - assert _codes_for(errors, "avito.accounts.domain.AccountHierarchy.link_items") == [ - "SWAGGER_BINDING_BODY_FIELD_NOT_FOUND" - ] - - -def test_lint_swagger_bindings_rejects_unsupported_body_schema_for_field_expression() -> None: - registry, discovery = _single_body_field_discovery( - expression="body.employee_id", - item_ids_expression="body", + path="/example", + operation_id="example", + deprecated=False, + parameters=(), request_body=SwaggerRequestBody( required=True, content_types=("application/json",), field_names=(), - schema_extracted=False, + schema_extracted=True, + schema=schema, ), - ) - - errors = lint_swagger_bindings(registry, discovery) - - assert _codes_for(errors, "avito.accounts.domain.AccountHierarchy.link_items") == [ - "SWAGGER_BINDING_BODY_SCHEMA_UNSUPPORTED" - ] - - -def test_lint_swagger_bindings_allows_whole_body_expression_with_unsupported_schema() -> None: - registry, discovery = _single_body_field_discovery( - expression="body", - item_ids_expression="body", - request_body=SwaggerRequestBody( - required=True, - content_types=("application/json",), - field_names=(), - schema_extracted=False, + responses=( + SwaggerResponse( + status_code="200", + content_types=("application/json",), + schema=schema, + ), + SwaggerResponse( + status_code="400", + content_types=("application/json",), + schema=schema, + ), ), ) - - errors = lint_swagger_bindings(registry, discovery) - - assert _codes_for(errors, "avito.accounts.domain.AccountHierarchy.link_items") == [] - - -def test_lint_swagger_bindings_requires_legacy_for_deprecated_operation() -> None: - registry = load_swagger_registry() - binding = SwaggerOperationBinding( - method="GET", - path="/autoload/v1/profile", - spec="Автозагрузка.json", - operation_id="getProfile", - factory="autoload_archive", - deprecated=True, + spec = OperationSpec[object]( + name="EXAMPLE", + method="POST", + path="/example", + error_models={}, ) - with _temporary_binding( - AutoloadArchive, - "get_profile", - binding, - spec=None, - factory=None, - ): - discovery = discover_swagger_bindings(registry=registry) - - errors = lint_swagger_bindings(registry, discovery) - - assert _codes_for(errors, "avito.ads.domain.AutoloadArchive.get_profile") == [ - "SWAGGER_BINDING_LEGACY_REQUIRED" - ] - - -def test_lint_swagger_bindings_requires_runtime_warning_for_deprecated_operation() -> None: - registry = load_swagger_registry() - binding = SwaggerOperationBinding( - method="GET", - path="/autoload/v1/profile", - spec="Автозагрузка.json", - operation_id="getProfile", - factory="account", - deprecated=True, - legacy=True, + errors = _validate_operation_json_body_models( + binding=binding, + operation=operation, + spec=spec, ) - with _temporary_binding( - Account, - "get_self", - binding, - spec=None, - factory=None, - ): - discovery = discover_swagger_bindings(registry=registry) - - errors = lint_swagger_bindings(registry, discovery) - - assert _codes_for( - errors, - "avito.accounts.domain.Account.get_self", - exclude={"SWAGGER_BINDING_DUPLICATE"}, - ) == ["SWAGGER_BINDING_DEPRECATION_WARNING_MISSING"] - - -def _codes_for( - errors: object, - sdk_method: str, - *, - exclude: set[str] | None = None, -) -> list[str]: - excluded = exclude or set() - return [ - error.code - for error in errors - if error.sdk_method == sdk_method and error.code not in excluded - ] - - -def _single_body_field_discovery( - *, - expression: str, - item_ids_expression: str = "body.employee_id", - request_body: SwaggerRequestBody, -) -> tuple[SwaggerRegistry, SwaggerBindingDiscovery]: - operation = SwaggerOperation( - spec="ИерархияАккаунтов.json", - method="POST", - path="/linkItemsV1", - operation_id="linkItemsV1", - deprecated=False, - parameters=(), - request_body=request_body, - responses=(SwaggerResponse(status_code="204", content_types=()),), - ) - registry = SwaggerRegistry( - specs=( - SwaggerSpec( - name="ИерархияАккаунтов.json", - path=load_swagger_registry().specs[0].path, - operations=(operation,), - ), - ) - ) - discovery = SwaggerBindingDiscovery( - bindings=( - DiscoveredSwaggerBinding( - module="avito.accounts.domain", - class_name="AccountHierarchy", - method_name="link_items", - domain="accounts", - operation_key=operation.key, - spec=operation.spec, - method=operation.method, - path=operation.path, - operation_id=operation.operation_id, - factory="account_hierarchy", - factory_args={}, - method_args={"employee_id": expression, "item_ids": item_ids_expression}, - ), - ) - ) - return registry, discovery + assert {error.code for error in errors} == { + "SWAGGER_CONTRACT_REQUEST_MODEL_MISSING", + "SWAGGER_CONTRACT_RESPONSE_MODEL_MISSING", + "SWAGGER_CONTRACT_ERROR_MODEL_MISSING", + } diff --git a/tests/core/test_swagger_report.py b/tests/core/test_swagger_report.py deleted file mode 100644 index cc2d27f..0000000 --- a/tests/core/test_swagger_report.py +++ /dev/null @@ -1,163 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator -from contextlib import contextmanager - -from avito.accounts.domain import Account -from avito.core.swagger import SwaggerOperationBinding -from avito.core.swagger_discovery import discover_swagger_bindings -from avito.core.swagger_registry import load_swagger_registry -from avito.core.swagger_report import build_swagger_binding_report - - -@contextmanager -def _temporary_account_bindings( - bindings: dict[str, SwaggerOperationBinding], - *, - spec: str | None = "Информацияопользователе.json", -) -> Iterator[None]: - original_bindings = { - method_name: getattr(getattr(Account, method_name), "__swagger_binding__", None) - for method_name in bindings - } - original_domain = getattr(Account, "__swagger_domain__", None) - original_spec = getattr(Account, "__swagger_spec__", None) - original_factory = getattr(Account, "__sdk_factory__", None) - Account.__swagger_domain__ = "accounts" - if spec is None and hasattr(Account, "__swagger_spec__"): - delattr(Account, "__swagger_spec__") - elif spec is not None: - Account.__swagger_spec__ = spec - Account.__sdk_factory__ = "account" - for method_name, binding in bindings.items(): - getattr(Account, method_name).__swagger_binding__ = binding - try: - yield - finally: - for method_name, original_binding in original_bindings.items(): - method = getattr(Account, method_name) - if original_binding is None: - delattr(method, "__swagger_binding__") - else: - method.__swagger_binding__ = original_binding - _restore_class_attribute(Account, "__swagger_domain__", original_domain) - _restore_class_attribute(Account, "__swagger_spec__", original_spec) - _restore_class_attribute(Account, "__sdk_factory__", original_factory) - - -def _restore_class_attribute(cls: type[object], name: str, value: object) -> None: - if value is None: - if hasattr(cls, name): - delattr(cls, name) - return - setattr(cls, name, value) - - -def test_build_swagger_binding_report_marks_current_corpus_as_complete() -> None: - registry = load_swagger_registry() - discovery = discover_swagger_bindings(registry=registry) - - report = build_swagger_binding_report(registry, discovery).to_dict() - - assert report["summary"] == { - "specs": 23, - "operations_total": 204, - "deprecated_operations": 7, - "bound": 204, - "unbound": 0, - "duplicate": 0, - "ambiguous": 0, - } - operations = report["operations"] - assert isinstance(operations, list) - assert operations[0].keys() >= { - "spec", - "method", - "path", - "operation_id", - "deprecated", - "status", - "binding", - } - assert {operation["status"] for operation in operations} == {"bound"} - - -def test_build_swagger_binding_report_marks_bound_operation() -> None: - registry = load_swagger_registry() - binding = SwaggerOperationBinding( - method="GET", - path="/core/v1/accounts/self", - operation_id="getUserInfoSelf", - ) - - with _temporary_account_bindings({"get_self": binding}): - discovery = discover_swagger_bindings(registry=registry) - - report = build_swagger_binding_report(registry, discovery).to_dict() - - assert report["summary"]["bound"] == 204 - operation = _find_operation( - report, - "Информацияопользователе.json GET /core/v1/accounts/self", - ) - assert operation["status"] == "bound" - assert operation["binding"]["sdk_method"] == "avito.accounts.domain.Account.get_self" - - -def test_build_swagger_binding_report_marks_duplicate_bindings() -> None: - registry = load_swagger_registry() - binding = SwaggerOperationBinding( - method="GET", - path="/core/v1/accounts/self", - operation_id="getUserInfoSelf", - ) - - with _temporary_account_bindings({"get_self": binding, "get_balance": binding}): - discovery = discover_swagger_bindings(registry=registry) - - report = build_swagger_binding_report(registry, discovery).to_dict() - - assert report["summary"]["duplicate"] == 1 - operation = _find_operation( - report, - "Информацияопользователе.json GET /core/v1/accounts/self", - ) - assert operation["status"] == "duplicate" - assert [binding["sdk_method"] for binding in operation["binding"]] == [ - "avito.accounts.domain.Account.get_balance", - "avito.accounts.domain.Account.get_self", - ] - - -def test_build_swagger_binding_report_marks_ambiguous_binding_without_operation_key() -> None: - registry = load_swagger_registry() - binding = SwaggerOperationBinding(method="POST", path="/token") - - with _temporary_account_bindings({"get_self": binding}, spec=None): - discovery = discover_swagger_bindings(registry=registry) - - report = build_swagger_binding_report(registry, discovery).to_dict() - - assert report["summary"]["ambiguous"] == 1 - binding = _find_binding(report, "avito.accounts.domain.Account.get_self") - assert binding["status"] == "ambiguous" - assert binding["operation_key"] is None - - -def _find_operation(report: dict[str, object], operation_key: str) -> dict[str, object]: - operations = report["operations"] - assert isinstance(operations, list) - for operation in operations: - key = f"{operation['spec']} {operation['method']} {operation['path']}" - if key == operation_key: - return operation - raise AssertionError(f"Operation not found: {operation_key}") - - -def _find_binding(report: dict[str, object], sdk_method: str) -> dict[str, object]: - bindings = report["bindings"] - assert isinstance(bindings, list) - for binding in bindings: - if binding["sdk_method"] == sdk_method: - return binding - raise AssertionError(f"Binding not found: {sdk_method}") diff --git a/tests/core/test_swagger_schema_paths.py b/tests/core/test_swagger_schema_paths.py new file mode 100644 index 0000000..d437631 --- /dev/null +++ b/tests/core/test_swagger_schema_paths.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import pytest + +from avito.core.swagger_registry import SwaggerSchema +from avito.core.swagger_schema_paths import SwaggerSchemaPathError, resolve_body_path + + +def test_resolve_body_path_accepts_array_object_leaf() -> None: + schema = SwaggerSchema( + kind="object", + properties={ + "dispatches": SwaggerSchema( + kind="array", + items=SwaggerSchema( + kind="object", + properties={"dispatchId": SwaggerSchema(kind="integer")}, + ), + ), + }, + ) + + path = resolve_body_path(schema, "dispatches[].dispatchId") + + assert path.leaf_name == "dispatchId" + assert path.leaf_schema.kind == "integer" + + +def test_resolve_body_path_accepts_snake_case_aliases() -> None: + schema = SwaggerSchema( + kind="object", + properties={ + "users": SwaggerSchema( + kind="array", + items=SwaggerSchema( + kind="object", + properties={"userId": SwaggerSchema(kind="integer")}, + ), + ), + }, + ) + + path = resolve_body_path(schema, "users[].user_id") + + assert path.leaf_name == "user_id" + assert path.leaf_schema.kind == "integer" + + +def test_resolve_body_path_accepts_literal_field_with_brackets() -> None: + schema = SwaggerSchema( + kind="object", + properties={"uploadfile[]": SwaggerSchema(kind="array")}, + ) + + path = resolve_body_path(schema, "uploadfile[]") + + assert path.leaf_name == "uploadfile" + assert path.leaf_schema.kind == "array" + + +def test_resolve_body_path_rejects_array_marker_on_object() -> None: + schema = SwaggerSchema( + kind="object", + properties={"schedule": SwaggerSchema(kind="object")}, + ) + + with pytest.raises(SwaggerSchemaPathError): + resolve_body_path(schema, "schedule[].id") diff --git a/tests/core/test_transport.py b/tests/core/test_transport.py index 2b4a01a..5bac9f7 100644 --- a/tests/core/test_transport.py +++ b/tests/core/test_transport.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import random as random_module from collections.abc import Iterator from datetime import UTC, datetime, timedelta @@ -20,8 +21,8 @@ RateLimitError, RequestContext, ResponseMappingError, - ServerError, Transport, + TransportError, UnsupportedOperationError, UpstreamApiError, ValidationError, @@ -110,6 +111,54 @@ def test_transport_appends_user_agent_suffix() -> None: assert fake_transport.last(path="/items").headers["user-agent"].endswith("ci/transport-tests") +def test_transport_logs_http_exchange_without_sensitive_fields( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.DEBUG, logger="avito.transport") + transport = Transport( + make_settings(retry_policy=RetryPolicy(max_attempts=1)), + client=httpx.Client( + transport=httpx.MockTransport( + lambda request: httpx.Response( + 200, + json={"ok": True}, + headers={"X-Request-Id": "req-123"}, + ) + ), + base_url="https://api.avito.ru", + ), + sleep=lambda _: None, + ) + + payload = transport.request_json( + "POST", + "/items", + context=RequestContext("create_item", allow_retry=True), + json_body={"secret": "value"}, + headers={"Authorization": "Bearer should-not-log"}, + idempotency_key="idem-should-not-log", + ) + + assert payload == {"ok": True} + exchange_records = [ + record for record in caplog.records if record.message == "transport http exchange" + ] + assert len(exchange_records) == 1 + record = exchange_records[0] + assert record.operation == "create_item" + assert record.endpoint == "/items" + assert record.method == "POST" + assert record.attempt == 1 + assert record.status == 200 + assert isinstance(record.latency_ms, int) + assert record.latency_ms >= 0 + assert record.request_id == "req-123" + assert not hasattr(record, "headers") + assert not hasattr(record, "json_body") + assert not hasattr(record, "idempotency_key") + assert "should-not-log" not in record.getMessage() + + def test_transport_refreshes_token_after_401() -> None: issued_tokens: Iterator[AccessToken] = iter( ( @@ -228,6 +277,33 @@ def handler(request: httpx.Request) -> httpx.Response: assert calls["count"] == 1 +def test_transport_retry_disabled_context_prevents_retryable_method_retry() -> None: + calls = {"count": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["count"] += 1 + if calls["count"] == 1: + return httpx.Response(500, json={"message": "server error"}) + 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, + ) + + with pytest.raises(UpstreamApiError): + transport.request_json( + "GET", + "/items", + context=RequestContext("list_items", retry_disabled=True), + ) + + 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] = [] @@ -286,6 +362,141 @@ def handler(request: httpx.Request) -> httpx.Response: assert calls["count"] == 1 +@pytest.mark.parametrize("failure", ("timeout", "server_error")) +def test_transport_does_not_retry_delete_without_idempotency_key(failure: str) -> None: + calls = {"count": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["count"] += 1 + if failure == "timeout": + raise httpx.ConnectError("offline", request=request) + return httpx.Response(500, json={"message": "server error"}) + + transport = Transport( + make_settings(), + client=httpx.Client( + transport=httpx.MockTransport(handler), base_url="https://api.avito.ru" + ), + sleep=lambda _: None, + ) + + if failure == "timeout": + with pytest.raises(Exception, match="offline"): + transport.request_json( + "DELETE", + "/items/1", + context=RequestContext("delete_item"), + ) + else: + with pytest.raises(UpstreamApiError): + transport.request_json( + "DELETE", + "/items/1", + context=RequestContext("delete_item"), + ) + + assert calls["count"] == 1 + + +def test_transport_retries_delete_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( + "DELETE", + "/items/1", + context=RequestContext("delete_item"), + idempotency_key="idem-123", + ) + + assert payload == {"ok": True} + assert calls["count"] == 2 + assert seen_keys == ["idem-123", "idem-123"] + + +def test_transport_retries_delete_with_explicit_retry_override() -> None: + calls = {"count": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["count"] += 1 + if calls["count"] == 1: + return httpx.Response(500, json={"message": "server error"}) + 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( + "DELETE", + "/items/1", + context=RequestContext("delete_item", allow_retry=True), + ) + + assert payload == {"ok": True} + assert calls["count"] == 2 + + +def test_transport_exposes_request_context_on_transport_error( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.DEBUG, logger="avito.transport") + + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("offline", request=request) + + transport = Transport( + make_settings(retry_policy=RetryPolicy(max_attempts=1)), + client=httpx.Client( + transport=httpx.MockTransport(handler), base_url="https://api.avito.ru" + ), + sleep=lambda _: None, + ) + + with pytest.raises(TransportError, match="offline") as error: + transport.request_json( + "POST", + "/items", + context=RequestContext("create_item", allow_retry=True), + json_body={"name": "item"}, + idempotency_key="idem-123", + ) + + assert error.value.operation == "create_item" + assert error.value.attempt == 1 + assert error.value.method == "POST" + assert error.value.endpoint == "/items" + exchange_records = [ + record for record in caplog.records if record.message == "transport http exchange" + ] + assert len(exchange_records) == 1 + assert exchange_records[0].operation == "create_item" + assert exchange_records[0].endpoint == "/items" + assert exchange_records[0].method == "POST" + assert exchange_records[0].attempt == 1 + assert exchange_records[0].status is None + assert exchange_records[0].request_id is None + + @pytest.mark.parametrize( ("status_code", "error_cls"), ( @@ -297,7 +508,7 @@ def handler(request: httpx.Request) -> httpx.Response: (418, UpstreamApiError), (422, ValidationError), (429, RateLimitError), - (500, ServerError), + (500, UpstreamApiError), ), ) def test_transport_maps_http_statuses_to_typed_sdk_errors( @@ -342,6 +553,9 @@ def test_transport_exposes_structured_error_fields() -> None: transport.request_json("GET", "/limited", context=RequestContext("limited")) assert error.value.operation == "limited" + assert error.value.attempt == 1 + assert error.value.method == "GET" + assert error.value.endpoint == "/limited" assert error.value.status == 429 assert error.value.error_code == "rate_limit" assert error.value.details == {"limit": "minute"} @@ -416,7 +630,10 @@ def test_transport_uses_half_second_retry_after_default_without_header() -> None assert error.value.retry_after == 0.5 -def test_transport_retries_rate_limit_without_retry_after_using_backoff() -> None: +def test_transport_retries_rate_limit_without_retry_after_using_backoff( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.DEBUG, logger="avito.transport") responses = iter( ( httpx.Response(429, json={"message": "Слишком много запросов."}), @@ -443,6 +660,17 @@ def test_transport_retries_rate_limit_without_retry_after_using_backoff() -> Non assert payload == {"ok": True} assert sleeps == [pytest.approx(random_module.Random(2).random())] + exchange_records = [ + record for record in caplog.records if record.message == "transport http exchange" + ] + assert [record.status for record in exchange_records] == [429, 200] + assert [record.attempt for record in exchange_records] == [1, 2] + retry_records = [record for record in caplog.records if record.message == "transport retry"] + assert len(retry_records) == 1 + assert retry_records[0].operation == "limited" + assert retry_records[0].endpoint == "/limited" + assert retry_records[0].method == "GET" + assert retry_records[0].status == 429 def test_transport_raises_mapping_error_for_invalid_json() -> None: diff --git a/tests/docs/conftest.py b/tests/docs/conftest.py deleted file mode 100644 index 8e85670..0000000 --- a/tests/docs/conftest.py +++ /dev/null @@ -1,758 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator - -import pytest - -from avito import AvitoClient -from avito.testing import FakeResponse, FakeTransport - - -def build_docs_client() -> AvitoClient: - fake = FakeTransport() - fake.add_json( - "GET", - "/core/v1/accounts/self", - {"id": 7, "name": "Иван", "email": "user@example.com", "phone": "+7999"}, - ) - fake.add_json( - "GET", - "/core/v1/accounts/7/balance/", - {"user_id": 7, "balance": {"real": 1500.0, "bonus": 250.0, "currency": "RUB"}}, - ) - fake.add_json( - "GET", - "/core/v1/accounts/123/balance/", - {"user_id": 123, "balance": {"real": 1500.0, "bonus": 250.0, "currency": "RUB"}}, - ) - fake.add_json( - "POST", - "/core/v1/accounts/operations_history/", - { - "total": 1, - "operations": [ - { - "id": "op-1", - "created_at": "2026-04-01T12:00:00Z", - "amount": -300.0, - "type": "payment", - "status": "done", - "description": "Оплата продвижения", - } - ], - }, - ) - fake.add_json( - "GET", - "/checkAhUserV1", - {"user_id": 7, "is_active": True, "role": "manager"}, - ) - fake.add_json( - "GET", - "/getEmployeesV1", - { - "employees": [ - {"employee_id": 10, "user_id": 7, "name": "Пётр", "email": "petr@example.com"} - ], - "total": 1, - }, - ) - fake.add_json( - "GET", - "/listCompanyPhonesV1", - {"phones": [{"id": 1, "phone": "+7000", "comment": "Основной"}]}, - ) - fake.add_json( - "POST", - "/listItemsByEmployeeIdV1", - { - "items": [{"item_id": 101, "title": "Диван", "status": "active", "price": 9900}], - "total": 1, - }, - ) - fake.add_json( - "POST", - "/linkItemsV1", - {"success": True, "message": "linked"}, - ) - fake.add_json( - "GET", - "/core/v1/items", - { - "items": [ - { - "id": 101, - "user_id": 7, - "title": "Диван", - "description": "Угловой диван", - "status": "active", - "price": 9900, - "url": "https://www.avito.ru/items/101", - }, - { - "id": 102, - "user_id": 7, - "title": "Кресло", - "status": "active", - "price": 3500, - }, - ], - "total": 2, - }, - ) - fake.add_json( - "GET", - "/core/v1/accounts/7/items/101/", - { - "id": 101, - "user_id": 7, - "title": "Диван", - "description": "Угловой диван", - "status": "active", - "price": 9900, - "url": "https://www.avito.ru/items/101", - }, - ) - fake.add_json( - "GET", - "/core/v1/accounts/123/items/42/", - { - "id": 42, - "user_id": 123, - "title": "Стол", - "description": "Письменный стол", - "status": "active", - "price": 4900, - "url": "https://www.avito.ru/items/42", - }, - ) - fake.add_json( - "POST", - "/stats/v1/accounts/7/items", - {"items": [{"item_id": 101, "views": 45, "contacts": 5, "favorites": 2}]}, - ) - fake.add_json( - "POST", - "/stats/v1/accounts/123/items", - {"items": [{"item_id": 42, "views": 45, "contacts": 5, "favorites": 2}]}, - ) - fake.add_json( - "POST", - "/stats/v2/accounts/7/items", - { - "period": "day", - "items": [{"item_id": 101, "views": 45, "contacts": 5, "favorites": 2}], - }, - ) - fake.add_json( - "POST", - "/stats/v2/accounts/123/items", - { - "period": "day", - "items": [{"item_id": 42, "views": 45, "contacts": 5, "favorites": 2}], - }, - ) - fake.add_json( - "POST", - "/core/v1/accounts/7/calls/stats/", - {"items": [{"item_id": 101, "calls": 3, "answered_calls": 2, "missed_calls": 1}]}, - ) - fake.add_json( - "POST", - "/stats/v2/accounts/7/spendings", - {"items": [{"item_id": 101, "amount": 77.5, "service": "xl"}], "total": 77.5}, - ) - fake.add_json( - "POST", - "/core/v1/accounts/7/vas/prices", - {"items": [{"code": "xl", "title": "XL", "price": 500, "is_available": True}]}, - ) - fake.add_json( - "POST", - "/core/v1/items/101/update_price", - {"item_id": 101, "price": 10900, "status": "updated"}, - ) - fake.add_json( - "POST", - "/cpa/v2/callsByTime", - { - "calls": [ - { - "callId": "10", - "itemId": "42", - "buyerPhone": "+79990000000", - "callTime": "2026-04-23T10:00:00Z", - "talkDuration": 60, - } - ] - }, - ) - fake.add_json( - "POST", - "/calltracking/v1/getCallById/", - { - "call": { - "callId": "10", - "itemId": "42", - "buyerPhone": "+79990000000", - "callTime": "2026-04-23T10:00:00Z", - "talkDuration": 60, - }, - "error": {"code": 0, "message": "ok"}, - }, - ) - fake.add_json( - "POST", - "/autoteka/v1/catalogs/resolve", - {"result": {"fields": [{"id": "brand", "label": "Марка", "type": "select"}]}}, - ) - fake.add_json( - "POST", - "/autoteka/v1/previews", - {"result": {"preview": {"previewId": "88", "status": "done", "vin": "XTA00000000000000"}}}, - ) - fake.add_json( - "POST", - "/autoteka/v1/reports", - { - "result": { - "report": { - "reportId": "99", - "status": "done", - "vin": "XTA00000000000000", - "createdAt": "2026-04-23T10:00:00Z", - } - } - }, - ) - fake.add_json( - "GET", - "/autoteka/v1/reports/list/", - { - "result": [ - { - "reportId": "99", - "status": "done", - "vin": "XTA00000000000000", - "createdAt": "2026-04-23T10:00:00Z", - } - ] - }, - ) - fake.add_json( - "POST", - "/core/v1/accounts/10/items/20/bookings", - {"result": "success"}, - ) - fake.add_json( - "GET", - "/realty/v1/accounts/10/items/20/bookings", - { - "bookings": [ - { - "id": 1, - "base_price": 5000, - "check_in": "2026-05-01", - "check_out": "2026-05-05", - "guest_count": 2, - "nights": 4, - "status": "active", - } - ] - }, - ) - fake.add_json( - "POST", - "/realty/v1/accounts/10/items/20/prices", - {"result": "success"}, - ) - fake.add( - "GET", - "/calltracking/v1/getRecordByCallId/", - FakeResponse( - 200, - content=b"docs-call-record", - headers={"content-type": "audio/mpeg"}, - ), - ) - fake.add_json( - "POST", - "/promotion/v1/items/services/orders/get", - { - "items": [ - { - "orderId": "ord-promo-1", - "itemId": 42, - "serviceCode": "xl", - "status": "active", - "createdAt": "2026-04-23T10:00:00Z", - } - ] - }, - ) - fake.add_json( - "POST", - "/promotion/v1/items/services/bbip/forecasts/get", - {"items": [{"itemId": 42, "min": 100, "max": 250, "totalPrice": 500}]}, - ) - fake.add_json( - "POST", - "/autostrategy/v1/budget", - { - "calcId": 55, - "budget": { - "recommended": {"total": 1000, "real": 800, "bonus": 200}, - "minimal": {"total": 500, "real": 500, "bonus": 0}, - "maximal": {"total": 2000, "real": 1800, "bonus": 200}, - "priceRanges": [], - }, - }, - ) - fake.add_json( - "POST", - "/autostrategy/v1/campaign/info", - { - "campaign": { - "campaignId": 15, - "campaignType": "AS", - "budget": 1000, - "balance": 900, - "title": "Весенняя кампания", - "statusId": 1, - "version": 3, - "userId": 7, - }, - "forecast": {"calls": {"from": 5, "to": 10}, "views": {"from": 100, "to": 250}}, - "items": [{"itemId": 42, "isActive": True}], - }, - ) - fake.add_json( - "POST", - "/autostrategy/v1/campaigns", - { - "campaigns": [ - { - "campaignId": 15, - "campaignType": "AS", - "budget": 1000, - "balance": 900, - "title": "Весенняя кампания", - "statusId": 1, - "version": 3, - "userId": 7, - } - ], - "totalCount": 1, - }, - ) - fake.add_json( - "GET", - "/autoload/v2/profile", - {"user_id": 123, "is_enabled": True, "upload_url": "https://autoload.example/upload"}, - ) - fake.add_json( - "GET", - "/autoload/v3/reports/777", - { - "report_id": 777, - "status": "done", - "created_at": "2026-04-23T10:00:00Z", - "finished_at": "2026-04-23T10:05:00Z", - "errors_count": 0, - "warnings_count": 0, - }, - ) - fake.add_json( - "GET", - "/autoload/v2/reports", - { - "reports": [ - { - "report_id": 777, - "status": "done", - "created_at": "2026-04-23T10:00:00Z", - "finished_at": "2026-04-23T10:05:00Z", - "processed_items": 1, - } - ], - "total": 1, - }, - ) - fake.add_json( - "GET", - "/messenger/v2/accounts/7/chats", - { - "chats": [ - { - "id": "chat-1", - "user_id": 7, - "title": "Покупатель", - "unread_count": 1, - "last_message": {"text": "Здравствуйте"}, - } - ], - "total": 1, - }, - ) - fake.add_json( - "GET", - "/messenger/v2/accounts/123/chats", - { - "chats": [ - { - "id": "chat-1", - "user_id": 123, - "title": "Покупатель", - "unread_count": 1, - "last_message": {"text": "Здравствуйте"}, - } - ], - "total": 1, - }, - ) - fake.add_json( - "GET", - "/messenger/v2/accounts/7/chats/chat-1", - { - "id": "chat-1", - "user_id": 7, - "title": "Покупатель", - "unread_count": 1, - "last_message": {"text": "Здравствуйте"}, - }, - ) - fake.add_json( - "GET", - "/messenger/v3/accounts/7/chats/chat-1/messages/", - { - "messages": [ - { - "id": "msg-0", - "chat_id": "chat-1", - "author_id": 100, - "text": "Здравствуйте", - "created_at": "2026-04-23T10:00:00Z", - "direction": "in", - "type": "text", - } - ], - "total": 1, - }, - ) - fake.add_json( - "POST", - "/messenger/v1/accounts/7/chats/chat-1/messages", - {"success": True, "message_id": "msg-1", "status": "sent"}, - ) - fake.add_json( - "POST", - "/messenger/v1/accounts/123/chats/chat-1/messages", - {"success": True, "message_id": "msg-1", "status": "sent"}, - ) - fake.add_json( - "POST", - "/messenger/v1/accounts/7/uploadImages", - {"images": [{"image_id": "img-1", "url": "https://cdn.example/img-1.jpg"}]}, - ) - fake.add_json( - "POST", - "/messenger/v1/accounts/123/uploadImages", - {"images": [{"image_id": "img-1", "url": "https://cdn.example/img-1.jpg"}]}, - ) - fake.add_json( - "POST", - "/messenger/v1/accounts/7/chats/chat-1/messages/image", - {"success": True, "message_id": "msg-img-1", "status": "sent"}, - ) - fake.add_json( - "POST", - "/messenger/v1/accounts/123/chats/chat-1/messages/image", - {"success": True, "message_id": "msg-img-1", "status": "sent"}, - ) - fake.add_json( - "POST", - "/messenger/v1/subscriptions", - { - "subscriptions": [ - { - "url": "https://example.com/messenger", - "version": "v3", - "status": "active", - } - ] - }, - ) - fake.add_json( - "POST", - "/messenger/v1/accounts/7/chats/chat-1/read", - {"success": True, "status": "read"}, - ) - fake.add_json( - "GET", - "/order-management/1/orders", - { - "orders": [ - { - "id": "ord-1", - "status": "new", - "created": "2026-04-23T09:00:00Z", - "buyerInfo": {"fullName": "Иван"}, - "totalPrice": 9900, - } - ], - "total": 1, - }, - ) - fake.add_json( - "POST", - "/order-management/1/order/applyTransition", - {"result": {"success": True, "orderId": "ord-1", "status": "confirmed"}}, - ) - fake.add_json( - "POST", - "/order-management/1/markings", - {"result": {"success": True, "orderId": "ord-1", "status": "marked"}}, - ) - fake.add_json( - "POST", - "/order-management/1/orders/labels", - {"result": {"taskId": 42, "status": "created"}}, - ) - fake.add( - "GET", - "/order-management/1/orders/labels/42/download", - FakeResponse( - 200, - content=b"%PDF-1.4 docs-label", - headers={ - "content-type": "application/pdf", - "content-disposition": 'attachment; filename="label-42.pdf"', - }, - ), - ) - fake.add_json( - "POST", - "/createAnnouncement", - {"data": {"taskId": 11, "status": "announcement-created"}}, - ) - fake.add_json( - "POST", - "/createParcel", - {"data": {"parcelId": "par-1", "status": "parcel-created"}}, - ) - fake.add_json( - "GET", - "/delivery-sandbox/tasks/11", - {"data": {"taskId": 11, "status": "done"}}, - ) - fake.add_json( - "POST", - "/stock-management/1/info", - { - "stocks": [ - { - "item_id": 101, - "quantity": 5, - "is_multiple": True, - "is_unlimited": False, - "is_out_of_stock": False, - } - ] - }, - ) - fake.add_json( - "PUT", - "/stock-management/1/stocks", - {"stocks": [{"item_id": 101, "external_id": "SKU-101", "success": True, "errors": []}]}, - ) - fake.add_json( - "GET", - "/job/v1/applications/get_ids", - { - "items": [{"id": "app-1", "updatedAt": "2026-04-23T10:00:00+03:00"}], - "cursor": "app-1", - }, - ) - fake.add_json( - "POST", - "/job/v1/applications/get_by_ids", - { - "applies": [ - { - "id": "app-1", - "vacancy_id": 101, - "resume_id": "res-1", - "state": "new", - "is_viewed": False, - "applicant": {"name": "Иван"}, - } - ] - }, - ) - fake.add_json( - "GET", - "/job/v1/applications/get_states", - {"states": [{"slug": "new", "description": "Новый отклик"}]}, - ) - fake.add_json( - "POST", - "/job/v1/applications/set_is_viewed", - {"ok": True, "status": "viewed"}, - ) - fake.add_json( - "POST", - "/job/v1/applications/apply_actions", - {"ok": True, "status": "invited"}, - ) - fake.add_json( - "GET", - "/job/v2/vacancies", - {"vacancies": [{"id": 101, "uuid": "vac-uuid-1", "title": "Продавец", "status": "active"}], "total": 1}, - ) - fake.add_json( - "GET", - "/job/v2/vacancies/101", - { - "id": 101, - "uuid": "vac-uuid-1", - "title": "Продавец", - "status": "active", - "url": "https://avito.ru/vacancy/101", - }, - ) - fake.add_json( - "GET", - "/job/v1/resumes/", - { - "meta": {"cursor": "2", "total": 1}, - "resumes": [ - { - "id": "res-1", - "title": "Оператор call-центра", - "name": "Пётр", - "location": "Москва", - "salary": 90000, - } - ], - }, - ) - fake.add_json( - "GET", - "/job/v2/resumes/res-1", - { - "id": "res-1", - "title": "Оператор call-центра", - "fullName": "Пётр Петров", - "address_details": {"location": "Москва"}, - "salary": {"from": 90000}, - }, - ) - fake.add_json( - "GET", - "/job/v1/resumes/res-1/contacts/", - {"name": "Пётр", "phone": "+79990000000", "email": "petr@example.com"}, - ) - fake.add_json( - "GET", - "/job/v1/applications/webhook", - {"url": "https://example.com/job", "is_active": True, "version": "v1"}, - ) - fake.add_json( - "GET", - "/job/v1/applications/webhooks", - {"items": [{"url": "https://example.com/job", "is_active": True, "version": "v1"}]}, - ) - fake.add_json( - "PUT", - "/job/v1/applications/webhook", - {"url": "https://example.com/job", "is_active": True, "version": "v1"}, - ) - fake.add_json( - "GET", - "/job/v2/vacancy/dict", - [{"id": "profession", "description": "Профессия"}], - ) - fake.add_json( - "GET", - "/job/v2/vacancy/dict/profession", - [{"id": 10106, "name": "IT, интернет, телеком", "deprecated": False}], - ) - fake.add_json( - "GET", - "/ratings/v1/info", - { - "isEnabled": True, - "rating": {"score": 4.7, "reviewsCount": 25, "reviewsWithScoreCount": 20}, - }, - ) - fake.add_json( - "GET", - "/ratings/v1/reviews", - { - "total": 25, - "reviews": [ - { - "id": 123, - "score": 5, - "stage": "done", - "text": "Все отлично", - "createdAt": 1713427200, - "canAnswer": True, - "usedInScore": True, - } - ], - }, - ) - fake.add_json( - "POST", - "/ratings/v1/answers", - {"id": 456, "createdAt": 1713427200}, - ) - fake.add_json( - "DELETE", - "/ratings/v1/answers/456", - {"success": True}, - ) - fake.add_json( - "GET", - "/tariff/info/1", - { - "current": { - "level": "Тариф Максимальный", - "isActive": True, - "startTime": 1713427200, - "closeTime": 1716029200, - "bonus": 10, - "packages": [{"id": 1}, {"id": 2}], - "price": {"price": 1990, "originalPrice": 2490}, - }, - "scheduled": { - "level": "Тариф Базовый", - "isActive": False, - "startTime": 1716029300, - "closeTime": None, - "bonus": 0, - "packages": [], - "price": {"price": 990, "originalPrice": 990}, - }, - }, - ) - return fake.as_client(user_id=7) - - -@pytest.fixture(autouse=True) -def docs_client_from_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: - monkeypatch.setenv("AVITO_CLIENT_ID", "docs-client-id") - monkeypatch.setenv("AVITO_CLIENT_SECRET", "docs-client-secret") - - def from_env( - cls: type[AvitoClient], - *, - env_file: str | None = ".env", - ) -> AvitoClient: - _ = cls - _ = env_file - return build_docs_client() - - monkeypatch.setattr(AvitoClient, "from_env", classmethod(from_env)) - yield diff --git a/tests/docs/test_docs_harness_surface.py b/tests/docs/test_docs_harness_surface.py deleted file mode 100644 index 440ecb1..0000000 --- a/tests/docs/test_docs_harness_surface.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -import inspect - -from avito import AvitoClient -from tests.docs.conftest import build_docs_client - - -def signature_without_self(callable_object: object) -> inspect.Signature: - signature = inspect.signature(callable_object) - parameters = [ - parameter - for name, parameter in signature.parameters.items() - if name != "self" - ] - return signature.replace(parameters=parameters) - - -def test_docs_harness_uses_real_public_client_surface() -> None: - client = build_docs_client() - account = client.account() - - assert isinstance(client, AvitoClient) - assert signature_without_self(type(client).account) == inspect.signature(client.account) - assert signature_without_self(type(account).get_self) == inspect.signature(account.get_self) diff --git a/tests/docs/test_markdown_examples.py b/tests/docs/test_markdown_examples.py deleted file mode 100644 index a839997..0000000 --- a/tests/docs/test_markdown_examples.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import mktestdocs - -DOCS_ROOT = Path(__file__).resolve().parents[2] -EXECUTABLE_MARKDOWN = [ - DOCS_ROOT / "README.md", - *sorted((DOCS_ROOT / "docs/site/tutorials").glob("*.md")), - *sorted((DOCS_ROOT / "docs/site/how-to").glob("*.md")), -] - - -def execute_pycon(source: str) -> None: - lines: list[str] = [] - for line in source.splitlines(): - if line.startswith(">>> "): - lines.append(line[4:]) - elif line.startswith("... "): - lines.append(line[4:]) - exec("\n".join(lines), {"__name__": "__main__"}) - - -mktestdocs.register_executor("pycon", execute_pycon) - - -def test_readme_tutorial_and_howto_python_examples_execute_without_network() -> None: - assert EXECUTABLE_MARKDOWN, "README/tutorials/how-to должны проверяться mktestdocs." - - for path in EXECUTABLE_MARKDOWN: - try: - mktestdocs.check_md_file(path, lang="python") - mktestdocs.check_md_file(path, lang="pycon") - except Exception as exc: # noqa: BLE001 - relative_path = path.relative_to(DOCS_ROOT) - raise AssertionError(f"Python/pycon-пример из {relative_path} не выполнился.") from exc diff --git a/tests/docs/test_no_placeholders.py b/tests/docs/test_no_placeholders.py deleted file mode 100644 index 5949812..0000000 --- a/tests/docs/test_no_placeholders.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -import re -from pathlib import Path - -DOCS_ROOT = Path(__file__).resolve().parents[2] -PLACEHOLDER_PATTERN = re.compile( - r"Раздел в разработке|placeholder|плейсхолдер|TODO|TBD|coming soon", - re.IGNORECASE, -) - - -def test_production_docs_do_not_contain_placeholders() -> None: - offenders: list[str] = [] - for path in sorted((DOCS_ROOT / "docs/site").rglob("*.md")): - text = path.read_text(encoding="utf-8") - if PLACEHOLDER_PATTERN.search(text): - offenders.append(str(path.relative_to(DOCS_ROOT))) - - assert offenders == [] diff --git a/tests/domains/accounts/test_accounts.py b/tests/domains/accounts/test_accounts.py index 091de92..fde7b92 100644 --- a/tests/domains/accounts/test_accounts.py +++ b/tests/domains/accounts/test_accounts.py @@ -22,9 +22,8 @@ def handler(request: httpx.Request) -> httpx.Response: ) assert request.url.path == "/core/v1/accounts/operations_history/" assert json.loads(request.content.decode()) == { - "dateFrom": "2025-01-01T00:00:00+00:00", - "limit": 2, - "offset": 0, + "dateTimeFrom": "2025-01-01T00:00:00+00:00", + "dateTimeTo": "2025-01-31T00:00:00+00:00", } return httpx.Response( 200, @@ -49,7 +48,7 @@ def handler(request: httpx.Request) -> httpx.Response: balance = account.get_balance() history = account.get_operations_history( date_from=datetime.fromisoformat("2025-01-01T00:00:00+00:00"), - limit=2, + date_to=datetime.fromisoformat("2025-01-31T00:00:00+00:00"), ) assert profile.user_id == 7 @@ -75,6 +74,17 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen_paths == ["/core/v1/accounts/self", "/core/v1/accounts/7/balance/"] +def test_account_balance_requires_keyword_user_id() -> None: + account = Account(make_transport(httpx.MockTransport(lambda request: httpx.Response(200)))) + + try: + account.get_balance(7) # type: ignore[misc] + except TypeError as error: + assert "positional" in str(error) + else: # pragma: no cover + raise AssertionError("get_balance accepted positional user_id") + + def test_account_hierarchy_domain_maps_employees_phones_and_items() -> None: def handler(request: httpx.Request) -> httpx.Response: if request.url.path == "/checkAhUserV1": @@ -92,7 +102,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert json.loads(request.content.decode()) == {"employeeId": 10, "itemIds": [1, 2]} return httpx.Response(200, json={"success": True, "message": "linked"}) assert request.url.path == "/listItemsByEmployeeIdV1" - assert json.loads(request.content.decode()) == {"employeeId": 10, "limit": 5, "offset": 0} + assert json.loads(request.content.decode()) == {"employeeId": 10, "categoryId": 24} return httpx.Response( 200, json={ @@ -107,7 +117,7 @@ def handler(request: httpx.Request) -> httpx.Response: employees = hierarchy.list_employees() phones = hierarchy.list_company_phones() linked = hierarchy.link_items(employee_id=10, item_ids=[1, 2]) - items = hierarchy.list_items_by_employee(employee_id=10, limit=5) + items = hierarchy.list_items_by_employee(employee_id=10, category_id=24) assert status.is_active is True assert employees.items[0].employee_id == 10 diff --git a/tests/domains/ads/test_ads.py b/tests/domains/ads/test_ads.py index 89a4e0f..ea25b4d 100644 --- a/tests/domains/ads/test_ads.py +++ b/tests/domains/ads/test_ads.py @@ -8,7 +8,8 @@ import pytest from avito.ads import Ad, AdPromotion, AdStats -from avito.ads.enums import ListingStatus +from avito.ads.models import AdAnalyticsGrouping, AdSpendingsGrouping, ListingStatus +from avito.core import ValidationError from tests.helpers.transport import make_transport @@ -110,7 +111,7 @@ def handler(request: httpx.Request) -> httpx.Response: 200, json={"items": [{"item_id": 101, "amount": 77.5, "service": "xl"}]} ) if request.url.path == "/core/v1/accounts/7/items/101/vas": - assert json.loads(request.content.decode()) == {"codes": ["xl"]} + assert json.loads(request.content.decode()) == {"vas_id": "xl"} return httpx.Response(200, json={"success": True, "status": "applied"}) assert request.url.path == "/core/v1/items/101/update_price" assert json.loads(request.content.decode()) == {"price": 1500} @@ -123,10 +124,15 @@ def handler(request: httpx.Request) -> httpx.Response: item = ad.get() updated = ad.update_price(price=1500) - item_stats = stats.get_item_stats() - calls = stats.get_calls_stats() - spendings = stats.get_account_spendings() - applied = promotion.apply_vas(codes=["xl"]) + item_stats = stats.get_item_stats(date_from="2026-04-01", date_to="2026-04-02") + calls = stats.get_calls_stats(date_from="2026-04-01", date_to="2026-04-02") + spendings = stats.get_account_spendings( + date_from="2026-04-01", + date_to="2026-04-02", + spending_types=["promotion"], + grouping=AdSpendingsGrouping.DAY, + ) + applied = promotion.apply_vas(vas_id="xl") assert item.title == "Смартфон" assert updated.status == "updated" @@ -147,10 +153,19 @@ def handler(request: httpx.Request) -> httpx.Response: started_at = datetime.fromisoformat("2026-04-18T00:00:00+03:00") finished_at = datetime.fromisoformat("2026-04-18T23:59:59+03:00") - stats.get_item_analytics(item_ids=[101], date_from=started_at, date_to=finished_at) + stats.get_item_analytics( + item_ids=[101], + date_from=started_at, + date_to=finished_at, + metrics=["views"], + grouping=AdAnalyticsGrouping.DAY, + limit=100, + offset=0, + ) assert seen_payloads[0]["dateFrom"] == "2026-04-18" assert seen_payloads[0]["dateTo"] == "2026-04-18" + assert seen_payloads[0]["grouping"] == "day" def test_ad_stats_accept_date_and_iso_string_filters() -> None: @@ -170,6 +185,30 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen_payloads[0]["dateTo"] == "2026-04-19" +def test_ad_stats_reject_unknown_grouping_before_transport() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError("transport must not be called") + + stats = AdStats(make_transport(httpx.MockTransport(handler)), item_id=101, user_id=7) + + with pytest.raises(ValidationError, match="grouping"): + stats.get_item_analytics( + date_from="2026-04-18", + date_to="2026-04-19", + metrics=["views"], + grouping="unknown", + limit=100, + offset=0, + ) + with pytest.raises(ValidationError, match="grouping"): + stats.get_account_spendings( + date_from="2026-04-18", + date_to="2026-04-19", + spending_types=["promotion"], + grouping="totals", + ) + + def test_ad_mapper_reads_nested_listing_fields() -> None: def handler(request: httpx.Request) -> httpx.Response: assert request.url.path == "/core/v1/accounts/7/items/101/" diff --git a/tests/domains/autoteka/test_autoteka.py b/tests/domains/autoteka/test_autoteka.py index fe9ef47..4c39704 100644 --- a/tests/domains/autoteka/test_autoteka.py +++ b/tests/domains/autoteka/test_autoteka.py @@ -11,9 +11,6 @@ AutotekaValuation, AutotekaVehicle, ) -from avito.autoteka.models import ( - MonitoringEventsQuery, -) from tests.helpers.transport import make_transport @@ -22,10 +19,41 @@ def handler(request: httpx.Request) -> httpx.Response: path = request.url.path payload = json.loads(request.content.decode()) if request.content else None if path == "/autoteka/v1/catalogs/resolve": - assert payload == {"brandId": 1} - return httpx.Response(200, json={"result": {"fields": [{"id": 110000, "label": "Марка", "dataType": "integer", "values": [{"valueId": 1, "label": "Audi"}]}]}}) + assert payload == {"fieldsValueIds": [{"id": 110000, "valueId": 1}]} + return httpx.Response( + 200, + json={ + "result": { + "fields": [ + { + "id": 110000, + "label": "Марка", + "dataType": "integer", + "values": [{"valueId": 1, "label": "Audi"}], + } + ] + } + }, + ) if path == "/autoteka/v1/get-leads/": - return httpx.Response(200, json={"pagination": {"lastId": 321}, "result": [{"id": 12, "subscriptionId": 44, "payload": {"vin": "VIN-1", "itemId": 901, "brand": "Audi", "model": "A4"}}]}) + return httpx.Response( + 200, + json={ + "pagination": {"lastId": 321}, + "result": [ + { + "id": 12, + "subscriptionId": 44, + "payload": { + "vin": "VIN-1", + "itemId": 901, + "brand": "Audi", + "model": "A4", + }, + } + ], + }, + ) if path == "/autoteka/v1/previews": return httpx.Response(200, json={"result": {"preview": {"previewId": 77}}}) if path == "/autoteka/v1/request-preview-by-item-id": @@ -35,27 +63,65 @@ def handler(request: httpx.Request) -> httpx.Response: if path == "/autoteka/v1/request-preview-by-external-item": return httpx.Response(200, json={"result": {"preview": {"previewId": 80}}}) if path == "/autoteka/v1/previews/77": - return httpx.Response(200, json={"result": {"preview": {"previewId": 77, "status": "success", "vin": "VIN-1", "regNumber": "A123AA77"}}}) + return httpx.Response( + 200, + json={ + "result": { + "preview": { + "previewId": 77, + "status": "success", + "vin": "VIN-1", + "regNumber": "A123AA77", + } + } + }, + ) if path == "/autoteka/v1/specifications/by-plate-number": return httpx.Response(200, json={"result": {"specification": {"specificationId": 501}}}) if path == "/autoteka/v1/specifications/by-vehicle-id": return httpx.Response(200, json={"result": {"specification": {"specificationId": 502}}}) if path == "/autoteka/v1/specifications/specification/501": - return httpx.Response(200, json={"result": {"specification": {"specificationId": 501, "status": "success", "vehicleId": "VIN-1"}}}) + return httpx.Response( + 200, + json={ + "result": { + "specification": { + "specificationId": 501, + "status": "success", + "vehicleId": "VIN-1", + } + } + }, + ) if path == "/autoteka/v1/teasers": - return httpx.Response(200, json={"result": {"teaser": {"teaserId": 601, "status": "processing"}}}) - return httpx.Response(200, json={"teaserId": 601, "status": "success", "data": {"brand": "Audi", "model": "A4", "year": 2018}}) + return httpx.Response( + 200, json={"result": {"teaser": {"teaserId": 601, "status": "processing"}}} + ) + return httpx.Response( + 200, + json={ + "teaserId": 601, + "status": "success", + "data": {"brand": "Audi", "model": "A4", "year": 2018}, + }, + ) vehicle = AutotekaVehicle(make_transport(httpx.MockTransport(handler)), vehicle_id="77") assert vehicle.resolve_catalog(brand_id=1).items[0].values[0].label == "Audi" - assert vehicle.get_leads(limit=1).last_id == 321 + assert vehicle.get_leads(subscription_id=44, limit=1).last_id == 321 assert vehicle.create_preview_by_vin(vin="VIN-1").preview_id == "77" assert vehicle.create_preview_by_item_id(item_id=901).preview_id == "78" assert vehicle.create_preview_by_reg_number(reg_number="A123AA77").preview_id == "79" - assert vehicle.create_preview_by_external_item(item_id="ext-1", site="cars.example").preview_id == "80" + assert ( + vehicle.create_preview_by_external_item(item_id="ext-1", site="cars.example").preview_id + == "80" + ) assert vehicle.get_preview().vehicle_id == "VIN-1" - assert vehicle.create_specification_by_plate_number(plate_number="A123AA77").specification_id == "501" + assert ( + vehicle.create_specification_by_plate_number(plate_number="A123AA77").specification_id + == "501" + ) assert vehicle.create_specification_by_vehicle_id(vehicle_id="VIN-1").specification_id == "502" assert vehicle.get_specification_by_id(specification_id="501").status == "success" assert vehicle.create_teaser(vehicle_id="VIN-1").teaser_id == "601" @@ -66,32 +132,134 @@ def test_autoteka_report_monitoring_scoring_and_valuation_flows() -> None: def handler(request: httpx.Request) -> httpx.Response: path = request.url.path if path == "/autoteka/v1/packages/active_package": - return httpx.Response(200, json={"result": {"package": {"createdTime": "2026-04-01", "expireTime": "2026-05-01", "reportsCnt": 100, "reportsCntRemain": 77}}}) + return httpx.Response( + 200, + json={ + "result": { + "package": { + "createdTime": "2026-04-01", + "expireTime": "2026-05-01", + "reportsCnt": 100, + "reportsCntRemain": 77, + } + } + }, + ) if path == "/autoteka/v1/reports": - return httpx.Response(200, json={"result": {"report": {"reportId": 701, "status": "processing"}}}) + return httpx.Response( + 200, json={"result": {"report": {"reportId": 701, "status": "processing"}}} + ) if path == "/autoteka/v1/reports-by-vehicle-id": - return httpx.Response(200, json={"result": {"report": {"reportId": 702, "status": "processing"}}}) + return httpx.Response( + 200, json={"result": {"report": {"reportId": 702, "status": "processing"}}} + ) if path == "/autoteka/v1/reports/list/": - return httpx.Response(200, json={"result": [{"reportId": 701, "vin": "VIN-1", "createdAt": "2026-04-18 12:00:00"}]}) + return httpx.Response( + 200, + json={ + "result": [ + {"reportId": 701, "vin": "VIN-1", "createdAt": "2026-04-18 12:00:00"} + ] + }, + ) if path == "/autoteka/v1/reports/701": - return httpx.Response(200, json={"result": {"report": {"reportId": 701, "status": "success", "webLink": "https://autoteka/web/701", "pdfLink": "https://autoteka/pdf/701", "data": {"vin": "VIN-1"}}}}) + return httpx.Response( + 200, + json={ + "result": { + "report": { + "reportId": 701, + "status": "success", + "webLink": "https://autoteka/web/701", + "pdfLink": "https://autoteka/pdf/701", + "data": {"vin": "VIN-1"}, + } + } + }, + ) if path == "/autoteka/v1/sync/create-by-regnumber": - return httpx.Response(200, json={"result": {"report": {"reportId": 703, "status": "success", "data": {"vin": "VIN-1"}}}}) + return httpx.Response( + 200, + json={ + "result": { + "report": {"reportId": 703, "status": "success", "data": {"vin": "VIN-1"}} + } + }, + ) if path == "/autoteka/v1/sync/create-by-vin": - return httpx.Response(200, json={"result": {"report": {"reportId": 704, "status": "success", "data": {"vin": "VIN-1"}}}}) + return httpx.Response( + 200, + json={ + "result": { + "report": {"reportId": 704, "status": "success", "data": {"vin": "VIN-1"}} + } + }, + ) if path == "/autoteka/v1/monitoring/bucket/add": - return httpx.Response(200, json={"result": {"isOk": True, "invalidVehicles": [{"vehicleID": "bad-vin", "description": "invalid"}]}}) + return httpx.Response( + 200, + json={ + "result": { + "isOk": True, + "invalidVehicles": [{"vehicleID": "bad-vin", "description": "invalid"}], + } + }, + ) if path == "/autoteka/v1/monitoring/bucket/delete": return httpx.Response(200, json={"result": {"isOk": True}}) if path == "/autoteka/v1/monitoring/bucket/remove": return httpx.Response(200, json={"result": {"isOk": True, "invalidVehicles": []}}) if path == "/autoteka/v1/monitoring/get-reg-actions/": - return httpx.Response(200, json={"data": [{"vin": "VIN-1", "brand": "Audi", "model": "A4", "year": 2018, "operationCode": 11, "operationDateFrom": "2026-04-01T00:00:00+03:00"}], "pagination": {"hasNext": True, "nextCursor": "cursor-2", "nextLink": "https://api.avito.ru/next"}}) + return httpx.Response( + 200, + json={ + "data": [ + { + "vin": "VIN-1", + "brand": "Audi", + "model": "A4", + "year": 2018, + "operationCode": 11, + "operationDateFrom": "2026-04-01T00:00:00+03:00", + } + ], + "pagination": { + "hasNext": True, + "nextCursor": "cursor-2", + "nextLink": "https://api.avito.ru/next", + }, + }, + ) if path == "/autoteka/v1/scoring/by-vehicle-id": return httpx.Response(200, json={"result": {"scoring": {"scoringId": 801}}}) if path == "/autoteka/v1/scoring/801": - return httpx.Response(200, json={"result": {"risksAssessment": {"scoringId": 801, "isCompleted": True, "createdAt": 1713427200}}}) - return httpx.Response(200, json={"result": {"status": "success", "vehicleId": "VIN-1", "brand": "Audi", "model": "A4", "year": 2018, "ownersCount": "2", "mileage": 30000, "valuation": {"avgPriceWithCondition": 2100000, "avgMarketPrice": 2200000}}}) + return httpx.Response( + 200, + json={ + "result": { + "risksAssessment": { + "scoringId": 801, + "isCompleted": True, + "createdAt": 1713427200, + } + } + }, + ) + return httpx.Response( + 200, + json={ + "result": { + "status": "success", + "vehicleId": "VIN-1", + "brand": "Audi", + "model": "A4", + "year": 2018, + "ownersCount": "2", + "mileage": 30000, + "valuation": {"avgPriceWithCondition": 2100000, "avgMarketPrice": 2200000}, + } + }, + ) transport = make_transport(httpx.MockTransport(handler)) report = AutotekaReport(transport, report_id="701") @@ -106,10 +274,20 @@ def handler(request: httpx.Request) -> httpx.Response: assert report.get_report().web_link == "https://autoteka/web/701" assert report.create_sync_report_by_reg_number(reg_number="A123AA77").status == "success" assert report.create_sync_report_by_vin(vin="VIN-1").report_id == "704" - assert monitoring.create_monitoring_bucket_add(vehicles=["VIN-1", "bad-vin"]).invalid_vehicles[0].vehicle_id == "bad-vin" + assert ( + monitoring.create_monitoring_bucket_add(vehicles=["VIN-1", "bad-vin"]) + .invalid_vehicles[0] + .vehicle_id + == "bad-vin" + ) assert monitoring.delete_bucket().success is True assert monitoring.remove_bucket(vehicles=["VIN-1"]).success is True - assert monitoring.get_monitoring_reg_actions(query=MonitoringEventsQuery(limit=10)).items[0].operation_code == 11 + assert monitoring.get_monitoring_reg_actions(limit=10).items[0].operation_code == 11 assert scoring.create_scoring_by_vehicle_id(vehicle_id="VIN-1").scoring_id == "801" assert scoring.get_scoring_by_id().is_completed is True - assert valuation.get_valuation_by_specification(specification_id=501, mileage=30000).avg_price_with_condition == 2100000 + assert ( + valuation.get_valuation_by_specification( + specification_id=501, mileage=30000 + ).avg_price_with_condition + == 2100000 + ) diff --git a/tests/domains/cpa/test_cpa.py b/tests/domains/cpa/test_cpa.py index 485727b..af18963 100644 --- a/tests/domains/cpa/test_cpa.py +++ b/tests/domains/cpa/test_cpa.py @@ -1,11 +1,14 @@ from __future__ import annotations import json +import logging import httpx import pytest +from avito.core import ValidationError from avito.cpa import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead +from avito.cpa.models import CpaCallStatusId from tests.helpers.transport import make_transport @@ -16,19 +19,34 @@ def handler(request: httpx.Request) -> httpx.Response: if path == "/cpa/v1/chatByActionId/act-1": return httpx.Response(200, json={"chat": {"chat": {"id": "chat-1", "actionId": "act-1"}, "buyer": {"userId": 501, "name": "Иван"}, "item": {"id": 9001, "title": "Велосипед"}, "isArbitrageAvailable": True}}) if path == "/cpa/v1/chatsByTime": - assert payload == {"createdAtFrom": "2026-04-18T00:00:00+03:00"} + assert payload == {"dateTimeFrom": "2026-04-18T00:00:00+03:00", "limit": 10, "offset": 0} return httpx.Response(200, json={"chats": [{"chat": {"id": "chat-v1", "actionId": "legacy-1"}, "buyer": {"userId": 502, "name": "Петр"}, "item": {"id": 9002, "title": "Самокат"}, "isArbitrageAvailable": False}]}) if path == "/cpa/v2/chatsByTime": + assert payload == {"dateTimeFrom": "2026-04-18T00:00:00+03:00", "limit": 10, "offset": 0} return httpx.Response(200, json={"chats": [{"chat": {"id": "chat-v2", "actionId": "act-2"}, "buyer": {"userId": 503, "name": "Мария"}, "item": {"id": 9003, "title": "Ноутбук"}, "isArbitrageAvailable": True}]}) + assert payload == {"dateTimeFrom": "2026-04-18T00:00:00+03:00", "limit": 10, "offset": 0} return httpx.Response(200, json={"total": 2, "results": [{"id": 101, "date": "2026-04-18T12:00:00+03:00", "phone_number": "+79990000001"}, {"id": 102, "date": "2026-04-18T12:05:00+03:00", "phone_number": "+79990000002"}]}) chat = CpaChat(make_transport(httpx.MockTransport(handler)), action_id="act-1") assert chat.get().item_title == "Велосипед" with pytest.deprecated_call(match="cpa_chat\\(\\)\\.list\\(version=2\\)"): - classic_chats = chat.list(created_at_from="2026-04-18T00:00:00+03:00", version=1) + classic_chats = chat.list( + created_at_from="2026-04-18T00:00:00+03:00", + limit=10, + offset=0, + version=1, + ) assert classic_chats.items[0].buyer_name == "Петр" - assert 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" + assert chat.list( + created_at_from="2026-04-18T00:00:00+03:00", + limit=10, + offset=0, + ).items[0].is_arbitrage_available is True + assert chat.get_phones_info_from_chats( + date_time_from="2026-04-18T00:00:00+03:00", + limit=10, + offset=0, + ).items[1].phone_number == "+79990000002" def test_cpa_calls_archive_and_balance_flows() -> None: @@ -55,9 +73,9 @@ def handler(request: httpx.Request) -> httpx.Response: cpa_lead = CpaLead(transport) archive = CpaArchive(transport, call_id="2001") - 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.list(date_time_from="2026-04-18T00:00:00+03:00", limit=100).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(action_id="act-1", reason="duplicate").success is True + assert cpa_lead.create_complaint_by_action_id(action_id=101, reason="duplicate").success is True assert cpa_lead.get_balance_info().balance == -5000 with pytest.deprecated_call(match="cpa_lead\\(\\)\\.get_balance_info"): archived_balance = archive.get_balance_info() @@ -70,6 +88,72 @@ def handler(request: httpx.Request) -> httpx.Response: assert archived_audio.binary.content == audio_bytes +def test_cpa_complaint_idempotency_key_is_stable_across_retry() -> 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) + assert request.url.path == "/cpa/v1/createComplaint" + return httpx.Response(200, json={"success": True}) + + cpa_call = CpaCall(make_transport(httpx.MockTransport(handler))) + + result = cpa_call.create_complaint( + call_id=2001, + reason="spam", + idempotency_key="idem-cpa-complaint", + ) + + assert result.success is True + assert calls["count"] == 2 + assert seen_keys == ["idem-cpa-complaint", "idem-cpa-complaint"] + + +def test_cpa_call_unknown_status_id_maps_to_unknown_and_warns_once( + caplog: pytest.LogCaptureFixture, +) -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/cpa/v2/callsByTime" + return httpx.Response( + 200, + json={ + "calls": [ + { + "id": 2001, + "itemId": 3001, + "statusId": 999, + } + ] + }, + ) + + caplog.set_level(logging.WARNING, logger="avito.core.enums") + cpa_call = CpaCall(make_transport(httpx.MockTransport(handler))) + + first = cpa_call.list( + date_time_from="2026-04-18T00:00:00+03:00", + limit=100, + ).items[0] + second = cpa_call.list( + date_time_from="2026-04-18T00:00:00+03:00", + limit=100, + ).items[0] + + assert first.status_id is CpaCallStatusId.UNKNOWN + assert second.status_id is CpaCallStatusId.UNKNOWN + records = [ + record + for record in caplog.records + if getattr(record, "enum", None) == "cpa.call_status_id" + and getattr(record, "value", None) == 999 + ] + assert len(records) == 1 + + def test_calltracking_flows() -> None: audio_bytes = b"RIFF fake wave" @@ -84,3 +168,19 @@ def handler(request: httpx.Request) -> httpx.Response: assert call.get().call.call_id == "7001" assert call.list(date_time_from="2026-04-01T00:00:00Z", date_time_to="2026-04-18T23:59:59Z", limit=100, offset=0).items[0].buyer_phone == "+79990000100" assert call.download().binary.content == audio_bytes + + +def test_cpa_rejects_invalid_datetime_before_transport() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError("transport must not be called") + + chat = CpaChat(make_transport(httpx.MockTransport(handler))) + call = CpaCall(make_transport(httpx.MockTransport(handler))) + tracking = CallTrackingCall(make_transport(httpx.MockTransport(handler))) + + with pytest.raises(ValidationError, match="created_at_from"): + chat.list(created_at_from="18.04.2026", limit=10, offset=0) + with pytest.raises(ValidationError, match="date_time_from"): + call.list(date_time_from="", limit=100) + with pytest.raises(ValidationError, match="date_time_to"): + tracking.list(date_time_from="2026-04-01T00:00:00Z", date_time_to="not-a-date") diff --git a/tests/domains/jobs/test_jobs.py b/tests/domains/jobs/test_jobs.py index 1d56c00..cff188b 100644 --- a/tests/domains/jobs/test_jobs.py +++ b/tests/domains/jobs/test_jobs.py @@ -1,12 +1,16 @@ from __future__ import annotations import httpx +import pytest +from avito.core import ValidationError from avito.jobs import Application, JobDictionary, JobWebhook, Resume, Vacancy from avito.jobs.models import ( - ApplicationIdsQuery, ApplicationViewedItem, - ResumeSearchQuery, + VacancyBillingType, + VacancyEmployment, + VacancyExperience, + VacancySchedule, ) from tests.helpers.transport import make_transport @@ -15,44 +19,105 @@ def test_application_webhook_and_resume_flows() -> None: def handler(request: httpx.Request) -> httpx.Response: path = request.url.path if path == "/job/v1/applications/get_ids": - return httpx.Response(200, json={"items": [{"id": "app-1", "updatedAt": "2026-04-18T10:00:00+03:00"}], "cursor": "app-1"}) + return httpx.Response( + 200, + json={ + "items": [{"id": "app-1", "updatedAt": "2026-04-18T10:00:00+03:00"}], + "cursor": "app-1", + }, + ) if path == "/job/v1/applications/get_by_ids": - return httpx.Response(200, json={"applies": [{"id": "app-1", "vacancy_id": 101, "state": "new", "is_viewed": False, "applicant": {"name": "Иван"}}]}) + return httpx.Response( + 200, + json={ + "applies": [ + { + "id": "app-1", + "vacancy_id": 101, + "state": "new", + "is_viewed": False, + "applicant": {"name": "Иван"}, + } + ] + }, + ) if path == "/job/v1/applications/get_states": - return httpx.Response(200, json={"states": [{"slug": "new", "description": "Новый отклик"}]}) + return httpx.Response( + 200, json={"states": [{"slug": "new", "description": "Новый отклик"}]} + ) if path == "/job/v1/applications/set_is_viewed": return httpx.Response(200, json={"ok": True, "status": "viewed"}) if path == "/job/v1/applications/apply_actions": return httpx.Response(200, json={"ok": True, "status": "invited"}) if path == "/job/v1/applications/webhook" and request.method == "GET": - return httpx.Response(200, json={"url": "https://example.com/job", "is_active": True, "version": "v1"}) + return httpx.Response( + 200, json={"url": "https://example.com/job", "is_active": True, "version": "v1"} + ) if path == "/job/v1/applications/webhook" and request.method == "PUT": - return httpx.Response(200, json={"url": "https://example.com/job", "is_active": True, "version": "v1"}) + return httpx.Response( + 200, json={"url": "https://example.com/job", "is_active": True, "version": "v1"} + ) if path == "/job/v1/applications/webhook" and request.method == "DELETE": return httpx.Response(200, json={"ok": True}) if path == "/job/v1/applications/webhooks": - return httpx.Response(200, json=[{"url": "https://example.com/job", "is_active": True, "version": "v1"}]) + return httpx.Response( + 200, json=[{"url": "https://example.com/job", "is_active": True, "version": "v1"}] + ) if path == "/job/v1/resumes/": - return httpx.Response(200, json={"meta": {"cursor": "2", "total": 1}, "resumes": [{"id": "res-1", "title": "Оператор call-центра", "name": "Петр", "location": "Москва", "salary": 90000}]} ) + return httpx.Response( + 200, + json={ + "meta": {"cursor": "2", "total": 1}, + "resumes": [ + { + "id": "res-1", + "title": "Оператор call-центра", + "name": "Петр", + "location": "Москва", + "salary": 90000, + } + ], + }, + ) if path == "/job/v1/resumes/res-1/contacts/": - return httpx.Response(200, json={"name": "Петр", "phone": "+79990000000", "email": "petr@example.com"}) - return httpx.Response(200, json={"id": "res-1", "title": "Оператор call-центра", "fullName": "Петр Петров", "address_details": {"location": "Москва"}, "salary": {"from": 90000}}) + return httpx.Response( + 200, json={"name": "Петр", "phone": "+79990000000", "email": "petr@example.com"} + ) + return httpx.Response( + 200, + json={ + "id": "res-1", + "title": "Оператор call-центра", + "fullName": "Петр Петров", + "address_details": {"location": "Москва"}, + "salary": {"from": 90000}, + }, + ) transport = make_transport(httpx.MockTransport(handler)) application = Application(transport) webhook = JobWebhook(transport) resume = Resume(transport, resume_id="res-1") - assert application.list(query=ApplicationIdsQuery(updated_at_from="2026-04-18")).items[0].id == "app-1" - assert application.list(ids=["app-1"]).items[0].applicant_name == "Иван" + assert application.get_ids(updated_at_from="2026-04-18").items[0].id == "app-1" + assert application.get_by_ids(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.update(applies=[ApplicationViewedItem(id="app-1", is_viewed=True)]).status + == "viewed" + ) assert application.apply(ids=["app-1"], action="invited").status == "invited" assert webhook.get().url == "https://example.com/job" - assert webhook.update(url="https://example.com/job").is_active is True + assert ( + webhook.update( + url="https://example.com/job", + secret="cb1e150b-c5bf-4c3e-acd1-20ec88bdb3a1", + ).is_active + is True + ) assert webhook.delete(url="https://example.com/job").success is True assert webhook.list().items[0].version == "v1" - assert resume.list(query=ResumeSearchQuery(query="оператор")).items[0].candidate_name == "Петр" + assert resume.list(query="оператор").items[0].candidate_name == "Петр" assert resume.get_contacts().phone == "+79990000000" assert resume.get().location == "Москва" @@ -69,37 +134,125 @@ def handler(request: httpx.Request) -> httpx.Response: if path == "/job/v1/vacancies/101/prolongate": return httpx.Response(200, json={"ok": True, "status": "prolongated"}) if path == "/job/v2/vacancies" and request.method == "GET": - return httpx.Response(200, json={"vacancies": [{"id": 101, "uuid": "vac-uuid-1", "title": "Продавец", "status": "active"}], "total": 1}) + return httpx.Response( + 200, + json={ + "vacancies": [ + {"id": 101, "uuid": "vac-uuid-1", "title": "Продавец", "status": "active"} + ], + "total": 1, + }, + ) if path == "/job/v2/vacancies": return httpx.Response(202, json={"vacancy_uuid": "vac-uuid-1", "status": "created"}) if path == "/job/v2/vacancies/batch": - return httpx.Response(200, json={"vacancies": [{"id": 101, "uuid": "vac-uuid-1", "title": "Продавец", "status": "active"}]}) + return httpx.Response( + 200, + json={ + "vacancies": [ + {"id": 101, "uuid": "vac-uuid-1", "title": "Продавец", "status": "active"} + ] + }, + ) if path == "/job/v2/vacancies/statuses": - return httpx.Response(200, json={"items": [{"id": 101, "uuid": "vac-uuid-1", "status": "active"}]}) + return httpx.Response( + 200, json={"items": [{"id": 101, "uuid": "vac-uuid-1", "status": "active"}]} + ) if path == "/job/v2/vacancies/update/vac-uuid-1": return httpx.Response(202, json={"vacancy_uuid": "vac-uuid-1", "status": "updated"}) if path == "/job/v2/vacancies/101": - return httpx.Response(200, json={"id": 101, "uuid": "vac-uuid-1", "title": "Продавец", "status": "active", "url": "https://avito.ru/vacancy/101"}) + return httpx.Response( + 200, + json={ + "id": 101, + "uuid": "vac-uuid-1", + "title": "Продавец", + "status": "active", + "url": "https://avito.ru/vacancy/101", + }, + ) if path == "/job/v2/vacancies/vac-uuid-1/auto_renewal": return httpx.Response(200, json={"ok": True, "status": "auto-renewal-updated"}) if path == "/job/v2/vacancy/dict": return httpx.Response(200, json=[{"id": "profession", "description": "Профессия"}]) - return httpx.Response(200, json=[{"id": 10106, "name": "IT, интернет, телеком", "deprecated": True}]) + return httpx.Response( + 200, json=[{"id": 10106, "name": "IT, интернет, телеком", "deprecated": True}] + ) transport = make_transport(httpx.MockTransport(handler)) vacancy = Vacancy(transport, vacancy_id="101") dictionary = JobDictionary(transport, dictionary_id="profession") - assert vacancy.create(title="Продавец", version=1).id == "101" - assert vacancy.update(title="Старший продавец", version=1).status == "updated" + assert ( + vacancy.create( + title="Продавец", + billing_type=VacancyBillingType.PACKAGE, + description="Описание вакансии", + business_area=7, + employment=VacancyEmployment.FULL, + schedule=VacancySchedule.FIXED, + experience=VacancyExperience.NO_MATTER, + version=1, + ).id + == "101" + ) + assert ( + vacancy.update(title="Старший продавец", billing_type="package", 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.create(title="Вакансия v2", billing_type=VacancyBillingType.PACKAGE).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(title="Вакансия v2 updated", version=2, vacancy_uuid="vac-uuid-1").status == "updated" + assert vacancy.get_statuses(ids=["vac-uuid-1"]).items[0].status == "active" + assert ( + vacancy.update( + title="Вакансия v2 updated", + billing_type="package", + version=2, + vacancy_uuid="vac-uuid-1", + ).status + == "updated" + ) assert vacancy.get().url == "https://avito.ru/vacancy/101" - assert vacancy.update_auto_renewal(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 + + +def test_application_rejects_invalid_updated_at_before_transport() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError("transport must not be called") + + application = Application(make_transport(httpx.MockTransport(handler))) + + with pytest.raises(ValidationError, match="updated_at_from"): + application.get_ids(updated_at_from="18-04-2026") + + +def test_vacancy_rejects_unknown_closed_values_before_transport() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError("transport must not be called") + + vacancy = Vacancy(make_transport(httpx.MockTransport(handler))) + + with pytest.raises(ValidationError, match="billing_type"): + vacancy.create(title="Вакансия", billing_type="unknown") + with pytest.raises(ValidationError, match="employment"): + vacancy.create( + title="Вакансия", + billing_type=VacancyBillingType.PACKAGE, + description="Описание", + business_area=7, + employment="unknown", + schedule=VacancySchedule.FIXED, + experience=VacancyExperience.NO_MATTER, + version=1, + ) diff --git a/tests/domains/messenger/test_messenger.py b/tests/domains/messenger/test_messenger.py index 96a4576..425255e 100644 --- a/tests/domains/messenger/test_messenger.py +++ b/tests/domains/messenger/test_messenger.py @@ -3,7 +3,9 @@ import json import httpx +import pytest +from avito.core import ValidationError from avito.messenger import Chat, ChatMedia, ChatMessage, ChatWebhook, SpecialOfferCampaign from avito.messenger.models import UploadImageFile from tests.helpers.transport import make_transport @@ -16,12 +18,19 @@ def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"chats": [{"id": "chat-1", "user_id": 7, "title": "Покупатель"}]}) if path == "/messenger/v2/accounts/7/chats/chat-1": return httpx.Response(200, json={"id": "chat-1", "user_id": 7, "title": "Покупатель"}) + if path == "/messenger/v2/accounts/7/blacklist": + assert json.loads(request.content.decode()) == {"users": [{"user_id": 42}]} + return httpx.Response(200, json={"success": True}) if path == "/messenger/v1/accounts/7/chats/chat-1/messages": - assert json.loads(request.content.decode()) == {"message": "Здравствуйте"} + assert json.loads(request.content.decode()) == { + "message": {"text": "Здравствуйте"}, + "type": "text", + } return httpx.Response(200, json={"success": True, "message_id": "msg-1", "status": "sent"}) if path == "/messenger/v1/accounts/7/uploadImages": return httpx.Response(200, json={"images": [{"image_id": "img-1", "url": "https://cdn/img-1.jpg"}]}) assert path == "/messenger/v1/accounts/7/chats/chat-1/messages/image" + assert json.loads(request.content.decode()) == {"image_id": "img-1"} return httpx.Response(200, json={"success": True, "message_id": "msg-img-1", "status": "sent"}) transport = make_transport(httpx.MockTransport(handler)) @@ -31,6 +40,7 @@ def handler(request: httpx.Request) -> httpx.Response: chats = chat.list() info = chat.get() + blocked = chat.blacklist(blacklisted_user_id=42) sent = message.send_message(chat_id="chat-1", message="Здравствуйте") uploaded = media.upload_images( files=[UploadImageFile(field_name="image", filename="photo.jpg", content=b"binary", content_type="image/jpeg")] @@ -39,6 +49,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert chats.items[0].chat_id == "chat-1" assert info.title == "Покупатель" + assert blocked.success is True assert sent.message_id == "msg-1" assert uploaded.items[0].image_id == "img-1" assert image_sent.message_id == "msg-img-1" @@ -54,11 +65,20 @@ def handler(request: httpx.Request) -> httpx.Response: assert payload == {"url": "https://example.com/hook", "secret": "top-secret"} return httpx.Response(200, json={"success": True, "status": "subscribed"}) if path == "/special-offers/v1/multiCreate": - assert payload == {"itemIds": [1], "message": "Скидка 10%", "discountPercent": 10} + assert payload == {"itemIds": [1]} return httpx.Response(200, json={"campaign_id": "camp-1", "status": "draft"}) if path == "/special-offers/v1/multiConfirm": + assert payload == { + "dispatches": [ + {"dispatchId": 1, "recipientsCount": 20, "offerSlug": "discount"} + ] + } return httpx.Response(200, json={"success": True, "status": "confirmed"}) assert path == "/special-offers/v1/stats" + assert payload == { + "dateTimeFrom": "2026-05-01T00:00:00+03:00", + "dateTimeTo": "2026-05-02T00:00:00+03:00", + } return httpx.Response(200, json={"campaign_id": "camp-1", "sent_count": 20, "delivered_count": 18, "read_count": 10}) transport = make_transport(httpx.MockTransport(handler)) @@ -67,12 +87,32 @@ def handler(request: httpx.Request) -> httpx.Response: subscriptions = webhook.list() subscribed = webhook.subscribe(url="https://example.com/hook", secret="top-secret") - created = campaign.create_multi(item_ids=[1], message="Скидка 10%", discount_percent=10) - confirmed = campaign.confirm_multi() - stats = campaign.get_stats() + created = campaign.create_multi(item_ids=[1]) + confirmed = campaign.confirm_multi( + dispatch_id=1, + recipients_count=20, + offer_slug="discount", + ) + stats = campaign.get_stats( + date_time_from="2026-05-01T00:00:00+03:00", + date_time_to="2026-05-02T00:00:00+03:00", + ) assert subscriptions.items[0].status == "active" assert subscribed.status == "subscribed" assert created.status == "draft" assert confirmed.status == "confirmed" assert stats.delivered_count == 18 + + +def test_special_offer_stats_reject_invalid_datetime_before_transport() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError("transport must not be called") + + campaign = SpecialOfferCampaign( + make_transport(httpx.MockTransport(handler)), + campaign_id="camp-1", + ) + + with pytest.raises(ValidationError, match="date_time_from"): + campaign.get_stats(date_time_from="not-a-date", date_time_to="2026-05-02T00:00:00Z") diff --git a/tests/domains/orders/test_orders.py b/tests/domains/orders/test_orders.py index 8636379..fa16836 100644 --- a/tests/domains/orders/test_orders.py +++ b/tests/domains/orders/test_orders.py @@ -3,8 +3,18 @@ import json import httpx +import pytest -from avito.orders import DeliveryOrder, DeliveryTask, Order, OrderLabel, Stock +from avito.core import ValidationError +from avito.orders import ( + DeliveryOrder, + DeliveryTask, + Order, + OrderLabel, + OrderTransition, + SandboxDelivery, + Stock, +) from avito.orders.models import ( StockUpdateEntry, ) @@ -18,7 +28,7 @@ def handler(request: httpx.Request) -> httpx.Response: if path == "/order-management/1/orders": return httpx.Response(200, json={"orders": [{"id": "ord-1", "status": "new", "buyerInfo": {"fullName": "Иван"}}], "total": 1}) if path == "/order-management/1/markings": - assert payload == {"orderId": "ord-1", "codes": ["abc"]} + assert payload == {"markings": [{"orderId": "ord-1", "markings": ["abc"]}]} return httpx.Response(200, json={"result": {"success": True, "orderId": "ord-1", "status": "marked"}}) if path == "/order-management/1/order/applyTransition": return httpx.Response(200, json={"result": {"success": True, "orderId": "ord-1", "status": "confirmed"}}) @@ -35,7 +45,7 @@ def handler(request: httpx.Request) -> httpx.Response: order = Order(make_transport(httpx.MockTransport(handler))) assert order.list().items[0].buyer_name == "Иван" assert order.update_markings(order_id="ord-1", codes=["abc"]).status == "marked" - assert order.apply(order_id="ord-1", transition="confirm").status == "confirmed" + assert order.apply(order_id="ord-1", transition=OrderTransition.CONFIRM).status == "confirmed" assert order.check_confirmation_code(order_id="ord-1", code="1234").status == "code-valid" assert order.get_courier_delivery_range().items[0].interval_id == "int-1" assert order.set_courier_delivery_range(order_id="ord-1", interval_id="int-1").status == "range-set" @@ -54,7 +64,9 @@ def handler(request: httpx.Request) -> httpx.Response: if path == "/order-management/1/orders/labels/42/download": return httpx.Response(200, content=pdf_bytes, headers={"content-type": "application/pdf", "content-disposition": 'attachment; filename="label-42.pdf"'}) if path == "/createAnnouncement": - assert payload == {"orderId": "ord-1"} + assert payload is not None + assert payload["announcementID"] == "ord-1" + assert "packages" in payload return httpx.Response(200, json={"data": {"taskId": 11, "status": "announcement-created"}}) if path == "/createParcel": return httpx.Response(200, json={"data": {"parcelId": "par-1", "status": "parcel-created"}}) @@ -88,3 +100,30 @@ def handler(request: httpx.Request) -> httpx.Response: assert task.get().status == "done" assert stock.get(item_ids=[123321]).items[0].quantity == 5 assert stock.update(stocks=[StockUpdateEntry(item_id=123321, quantity=7)]).items[0].success is True + + +def test_sandbox_delivery_rejects_invalid_event_dates_before_transport() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError("transport must not be called") + + delivery = SandboxDelivery(make_transport(httpx.MockTransport(handler))) + + with pytest.raises(ValidationError, match="date"): + delivery.tracking( + order_id="ord-1", + avito_status="CONFIRMED", + avito_event_type="", + provider_event_code="accepted", + date="not-a-date", + location="Москва", + ) + + +def test_order_apply_rejects_unknown_transition_before_transport() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError("transport must not be called") + + order = Order(make_transport(httpx.MockTransport(handler))) + + with pytest.raises(ValidationError, match="transition"): + order.apply(order_id="ord-1", transition="unknown") diff --git a/tests/domains/promotion/test_promotion.py b/tests/domains/promotion/test_promotion.py index 75346a7..d271a5b 100644 --- a/tests/domains/promotion/test_promotion.py +++ b/tests/domains/promotion/test_promotion.py @@ -17,13 +17,13 @@ TargetActionPricing, TrxPromotion, ) -from avito.promotion.enums import ( +from avito.promotion.models import ( + BbipItem, + CpaAuctionBidInput, PromotionOrderServiceStatus, TargetActionBudgetType, TargetActionSelectedType, -) -from avito.promotion.models import ( - BbipItem, + TrxItem, ) from tests.helpers.transport import make_transport @@ -38,7 +38,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert payload == {"itemIds": [101]} return httpx.Response(200, json={"items": [{"itemId": 101, "serviceCode": "x2", "serviceName": "X2", "price": 9900, "status": "available"}]}) if path == "/promotion/v1/items/services/orders/get": - assert payload == {"itemIds": [101]} + assert payload == {} return httpx.Response(200, json={"items": [{"orderId": "ord-1", "itemId": 101, "serviceCode": "x2", "status": "created"}]}) assert path == "/promotion/v1/items/services/orders/status" return httpx.Response(200, json={"orderId": "ord-1", "status": "processed", "items": [], "errors": []}) @@ -87,12 +87,12 @@ def handler(request: httpx.Request) -> httpx.Response: auction = CpaAuction(transport) pricing = TargetActionPricing(transport, item_id=101) - bbip_item = BbipItem(item_id=101, duration=7, price=1000, old_price=1200).to_dict() - trx_item = { - "item_id": 101, - "commission": 1500, - "date_from": datetime.fromisoformat("2026-04-18T00:00:00+00:00"), - } + bbip_item = BbipItem(item_id=101, duration=7, price=1000, old_price=1200) + trx_item = TrxItem( + item_id=101, + commission=1500, + date_from=datetime.fromisoformat("2026-04-18T00:00:00+00:00"), + ) assert bbip.get_forecasts(items=[bbip_item]).items[0].max_views == 25 assert bbip.create_order(items=[bbip_item]).status == "created" @@ -101,7 +101,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert trx.delete().applied is True assert trx.get_commissions().items[0].valid_commission_range is not None assert auction.get_user_bids(from_item_id=100, batch_size=50).items[0].available_prices[0].price_penny == 1200 - assert auction.create_item_bids(items=[{"item_id": 101, "price_penny": 1500}]).applied is True + assert auction.create_item_bids(items=[CpaAuctionBidInput(item_id=101, price_penny=1500)]).applied is True assert pricing.get_bids().manual is not None assert pricing.get_promotions_by_item_ids(item_ids=[101, 102]).items[0].auto is not None assert pricing.delete().status == "removed" @@ -184,14 +184,14 @@ def handler(request: httpx.Request) -> httpx.Response: ad_promotion = AdPromotion(transport, item_id=101, user_id=7) bbip = BbipPromotion(transport, item_id=101) trx = TrxPromotion(transport, item_id=101) - bbip_item = BbipItem(item_id=101, duration=7, price=1000, old_price=1200).to_dict() - trx_item = { - "item_id": 101, - "commission": 1500, - "date_from": datetime.fromisoformat("2026-04-18T00:00:00+00:00"), - } - - vas_preview = ad_promotion.apply_vas(codes=["xl"], dry_run=True) + bbip_item = BbipItem(item_id=101, duration=7, price=1000, old_price=1200) + trx_item = TrxItem( + item_id=101, + commission=1500, + date_from=datetime.fromisoformat("2026-04-18T00:00:00+00:00"), + ) + + vas_preview = ad_promotion.apply_vas(vas_id="xl", dry_run=True) bbip_preview = bbip.create_order(items=[bbip_item], dry_run=True) trx_preview = trx.apply(items=[trx_item], dry_run=True) @@ -199,7 +199,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert bbip_preview.status == "preview" assert trx_preview.status == "preview" - vas_apply = ad_promotion.apply_vas(codes=["xl"]) + vas_apply = ad_promotion.apply_vas(vas_id="xl") bbip_apply = bbip.create_order(items=[bbip_item]) trx_apply = trx.apply(items=[trx_item]) @@ -222,12 +222,12 @@ def handler(request: httpx.Request) -> httpx.Response: 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_item = BbipItem(item_id=101, duration=7, price=1000, old_price=1200) + trx_item = TrxItem( + item_id=101, + commission=1500, + date_from=datetime.fromisoformat("2026-04-18T00:00:00+00:00"), + ) bbip.create_order(items=[bbip_item], dry_run=True) trx.apply(items=[trx_item], dry_run=True) diff --git a/tests/domains/ratings/test_ratings.py b/tests/domains/ratings/test_ratings.py index 2bedead..11d340e 100644 --- a/tests/domains/ratings/test_ratings.py +++ b/tests/domains/ratings/test_ratings.py @@ -5,7 +5,6 @@ import httpx from avito.ratings import RatingProfile, Review, ReviewAnswer -from avito.ratings.models import ReviewsQuery from tests.helpers.transport import make_transport @@ -13,13 +12,38 @@ def test_ratings_flows() -> None: def handler(request: httpx.Request) -> httpx.Response: path = request.url.path if path == "/ratings/v1/answers": - assert json.loads(request.content.decode()) == {"reviewId": 123, "text": "Спасибо за отзыв"} + assert json.loads(request.content.decode()) == { + "reviewId": 123, + "message": "Спасибо за отзыв", + } return httpx.Response(200, json={"id": 456, "createdAt": 1713427200}) if path == "/ratings/v1/answers/456": return httpx.Response(200, json={"success": True}) if path == "/ratings/v1/info": - return httpx.Response(200, json={"isEnabled": True, "rating": {"score": 4.7, "reviewsCount": 25, "reviewsWithScoreCount": 20}}) - return httpx.Response(200, json={"total": 25, "reviews": [{"id": 123, "score": 5, "stage": "done", "text": "Все отлично", "createdAt": 1713427200, "canAnswer": True, "usedInScore": True}]}) + return httpx.Response( + 200, + json={ + "isEnabled": True, + "rating": {"score": 4.7, "reviewsCount": 25, "reviewsWithScoreCount": 20}, + }, + ) + return httpx.Response( + 200, + json={ + "total": 25, + "reviews": [ + { + "id": 123, + "score": 5, + "stage": "done", + "text": "Все отлично", + "createdAt": 1713427200, + "canAnswer": True, + "usedInScore": True, + } + ], + }, + ) transport = make_transport(httpx.MockTransport(handler)) answer = ReviewAnswer(transport, answer_id="456") @@ -29,7 +53,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert answer.create(review_id=123, text="Спасибо за отзыв").answer_id == "456" assert answer.delete().success is True assert profile.get().score == 4.7 - assert review.list(query=ReviewsQuery(page=2)).items[0].text == "Все отлично" + assert review.list(page=2).items[0].text == "Все отлично" def test_review_list_uses_working_default_page() -> None: diff --git a/tests/domains/realty/test_realty.py b/tests/domains/realty/test_realty.py index b8d39ec..85cf91d 100644 --- a/tests/domains/realty/test_realty.py +++ b/tests/domains/realty/test_realty.py @@ -3,7 +3,9 @@ import json import httpx +import pytest +from avito.core import ValidationError from avito.realty import RealtyAnalyticsReport, RealtyBooking, RealtyListing, RealtyPricing from avito.realty.models import ( RealtyInterval, @@ -17,7 +19,11 @@ def handler(request: httpx.Request) -> httpx.Response: path = request.url.path payload = json.loads(request.content.decode()) if request.content else None if path == "/core/v1/accounts/10/items/20/bookings": - assert payload == {"blockedDates": ["2026-04-18"]} + assert payload == { + "bookings": [ + {"date_start": "2026-04-18", "date_end": "2026-04-18"} + ] + } return httpx.Response(200, json={"result": "success"}) if path == "/realty/v1/accounts/10/items/20/bookings": assert request.url.params["date_start"] == "2026-05-01" @@ -25,10 +31,18 @@ def handler(request: httpx.Request) -> httpx.Response: assert request.url.params["with_unpaid"] == "true" return httpx.Response(200, json={"bookings": [{"avito_booking_id": 777, "status": "active", "check_in": "2026-05-01", "check_out": "2026-05-05", "guest_count": 2, "nights": 4, "base_price": 12000, "contact": {"name": "Иван", "email": "ivan@example.com", "phone": "9997770000"}, "safe_deposit": {"owner_amount": 4500, "tax": 500, "total_amount": 5000}}]}) if path == "/realty/v1/accounts/10/items/20/prices": + assert payload == {"prices": [{"date_from": "2026-05-01", "night_price": 5000}]} return httpx.Response(200, json={"result": "success"}) if path == "/realty/v1/items/intervals": + assert payload == { + "item_id": 20, + "intervals": [ + {"date_start": "2026-05-01", "date_end": "2026-05-01", "open": 1} + ], + } return httpx.Response(200, json={"result": "success"}) if path == "/realty/v1/items/20/base": + assert payload == {"minimal_duration": 2} return httpx.Response(200, json={"result": "success"}) if path == "/realty/v1/marketPriceCorrespondence/20/5000000": return httpx.Response(200, json={"correspondence": "normal"}) @@ -49,3 +63,48 @@ def handler(request: httpx.Request) -> httpx.Response: 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" + + +def test_realty_write_operation_forwards_idempotency_key() -> None: + seen_keys: list[str | None] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_keys.append(request.headers.get("Idempotency-Key")) + assert request.url.path == "/realty/v1/accounts/10/items/20/prices" + assert json.loads(request.content.decode()) == { + "prices": [{"date_from": "2026-05-01", "night_price": 5000}] + } + return httpx.Response(200, json={"result": "success"}) + + pricing = RealtyPricing( + make_transport(httpx.MockTransport(handler)), + item_id="20", + user_id="10", + ) + + result = pricing.update_realty_prices( + periods=[RealtyPricePeriod(date_from="2026-05-01", price=5000)], + idempotency_key="idem-realty-prices", + ) + + assert result.status == "success" + assert seen_keys == ["idem-realty-prices"] + + +def test_realty_rejects_invalid_dates_before_transport() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError("transport must not be called") + + transport = make_transport(httpx.MockTransport(handler)) + booking = RealtyBooking(transport, item_id="20", user_id="10") + pricing = RealtyPricing(transport, item_id="20", user_id="10") + listing = RealtyListing(transport, item_id="20") + + with pytest.raises(ValidationError, match="date_start"): + booking.list_realty_bookings(date_start="01.05.2026", date_end="2026-05-05") + with pytest.raises(ValidationError, match="blocked_dates"): + booking.update_bookings_info(blocked_dates=["not-a-date"]) + with pytest.raises(ValidationError, match="date_from"): + pricing.update_realty_prices(periods=[RealtyPricePeriod(date_from="not-a-date", price=5000)]) + with pytest.raises(ValidationError, match="date"): + listing.get_intervals(intervals=[RealtyInterval(date="not-a-date", available=True)]) diff --git a/tests/test_download_avito_api_specs.py b/tests/test_download_avito_api_specs.py deleted file mode 100644 index bb4cb8a..0000000 --- a/tests/test_download_avito_api_specs.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -import subprocess - -import download_avito_api_specs as downloader -import pytest - - -def test_run_curl_uses_timeout_options(monkeypatch: pytest.MonkeyPatch) -> None: - def fake_run(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: - assert command == [ - "curl", - "-fsSL", - "--connect-timeout", - "30", - "--max-time", - "180", - "https://developers.avito.ru/web/1/openapi/info/delivery-sandbox", - ] - return subprocess.CompletedProcess( - args=command, - returncode=0, - stdout='{"ok": true}', - stderr="", - ) - - monkeypatch.setattr(downloader.subprocess, "run", fake_run) - - body = downloader.run_curl( - "https://developers.avito.ru/web/1/openapi/info/delivery-sandbox", - "https://developers.avito.ru/api-catalog/delivery-sandbox/documentation", - ) - - assert body == '{"ok": true}' - - -def test_run_curl_reports_public_catalog_url_on_failure( - monkeypatch: pytest.MonkeyPatch, -) -> None: - def fake_run(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: - return subprocess.CompletedProcess( - args=command, - returncode=28, - stdout="", - stderr="curl: (28) SSL connection timeout", - ) - - monkeypatch.setattr(downloader.subprocess, "run", fake_run) - - with pytest.raises(RuntimeError) as exc_info: - downloader.run_curl( - "https://developers.avito.ru/web/1/openapi/info/delivery-sandbox", - "https://developers.avito.ru/api-catalog/delivery-sandbox/documentation", - ) - - assert str(exc_info.value) == ( - "Не удалось скачать https://developers.avito.ru/api-catalog/delivery-sandbox/documentation: " - "curl: (28) SSL connection timeout" - ) - - -def test_fetch_catalog_builds_public_documentation_urls( - monkeypatch: pytest.MonkeyPatch, -) -> None: - responses = { - "https://developers.avito.ru/api-catalog": ( - '' - ), - "https://developers.avito.ru/dstatic/build/open-api-dev-portal.hash.js": ( - 'const base="/web/7/openapi";' - ), - "https://developers.avito.ru/web/7/openapi/list": ( - '[{"slug": "delivery-sandbox", "title": "Доставка"}]' - ), - } - - def fake_run_curl(url: str, source_url: str) -> str: - assert source_url == "https://developers.avito.ru/api-catalog" - return responses[url] - - monkeypatch.setattr(downloader, "run_curl", fake_run_curl) - - assert downloader.fetch_catalog() == [ - downloader.ApiCatalogItem( - slug="delivery-sandbox", - title="Доставка", - documentation_url=( - "https://developers.avito.ru/api-catalog/delivery-sandbox/documentation" - ), - ) - ] diff --git a/todo.md b/todo.md deleted file mode 100644 index cef6d52..0000000 --- a/todo.md +++ /dev/null @@ -1,435 +0,0 @@ -# Swagger Binding Decorator - -## Цель - -В SDK должен быть единый машинно-проверяемый способ связать публичный SDK-метод с операцией из Swagger-спецификации. - -Swagger-файлы в `docs/avito/api/*.json` являются единственным источником истины по API-контракту: - -- HTTP method; -- path; -- path/query/header parameters; -- request body; -- content-type; -- response statuses; -- response schemas; -- error schemas; -- deprecated state. - -Декоратор и class-level metadata не должны дублировать API-контракт. Они описывают только: - -```text -какой SDK method соответствует какой Swagger operation -как contract-test runner должен вызвать этот SDK method -``` - -## Сценарий Выполнения - -Перед внедрением binding-ов нужно обновить локальные Swagger/OpenAPI спецификации из публичного каталога Авито: - -```bash -poetry run python scripts/download_avito_api_specs.py -``` - -Сценарий работ: - -1. Запустить `poetry run python scripts/download_avito_api_specs.py`, чтобы `docs/avito/api/*.json` отражали актуальный источник API-контрактов. -2. Проверить diff Swagger-файлов и зафиксировать, если изменилось число операций, `operation_id`, `deprecated`, paths, параметры или схемы. -3. Реализовать `avito/core/swagger.py` с `SwaggerOperationBinding` и `@swagger_operation(...)`. -4. Расставить class-level metadata и decorators на публичных domain methods без дублирования Swagger-контракта. -5. Реализовать `scripts/lint_swagger_bindings.py` и `make swagger-lint`. -6. Добавить `make swagger-lint` в общий quality gate. -7. Добавить unit-тесты декоратора, линтера и contract tests через `SwaggerFakeTransport`. -8. Завершить проверкой: - -```bash -make check -make swagger-lint -``` - -## 1. Публичный API Декоратора - -Модуль: - -```text -avito/core/swagger.py -``` - -Основной декоратор: - -```python -@swagger_operation( - method: str, - path: str, - *, - spec: str | None = None, - operation_id: str | None = None, - factory: str | None = None, - factory_args: Mapping[str, str] | None = None, - method_args: Mapping[str, str] | None = None, - deprecated: bool = False, - legacy: bool = False, -) -``` - -Пример: - -```python -class Chat: - __swagger_domain__ = "messenger" - __swagger_spec__ = "Мессенджер.json" - __sdk_factory__ = "chat" - - @swagger_operation( - "GET", - "/messenger/v1/accounts/{user_id}/chats/{chat_id}", - factory_args={ - "user_id": "path.user_id", - "chat_id": "path.chat_id", - }, - ) - def get(self) -> ChatInfo: - ... -``` - -## 2. Class-Level Metadata - -Публичные domain objects и section clients могут объявлять служебные поля: - -```python -__swagger_domain__: str -__swagger_spec__: str -__sdk_factory__: str -__sdk_factory_args__: Mapping[str, str] -``` - -Назначение: - -```text -__swagger_domain__ - Логический домен SDK: ads, messenger, orders, promotion, accounts и т.д. - Используется для группировки contract tests и отчетов линтера. - -__swagger_spec__ - Имя Swagger-файла из docs/avito/api/. - Используется как default spec для всех decorated методов класса. - -__sdk_factory__ - Имя factory method на AvitoClient. - Например: "chat" означает client.chat(...). - -__sdk_factory_args__ - Default mapping аргументов factory. - Используется, если method-level factory_args не указан. -``` - -Приоритет значений: - -```text -1. Значения из @swagger_operation(...) -2. Значения из class-level metadata -3. Auto-resolve через Swagger registry, если это безопасно и однозначно -``` - -## 3. Binding Model - -Декоратор должен записывать metadata в атрибут функции: - -```python -func.__swagger_binding__ -``` - -Тип: - -```python -@dataclass(frozen=True, slots=True) -class SwaggerOperationBinding: - method: str - path: str - spec: str | None - operation_id: str | None - factory: str | None - factory_args: Mapping[str, str] - method_args: Mapping[str, str] - deprecated: bool - legacy: bool -``` - -Требования: - -- `method` нормализуется в uppercase. -- `path` хранится в Swagger-формате: `/path/{param}`. -- `factory_args` и `method_args` внутри модели должны быть immutable mapping. -- Декоратор не должен менять поведение метода. -- Декоратор не должен выполнять загрузку Swagger-файлов на import time. - -## 4. Запрещенные Поля - -В декораторе запрещены любые поля, дублирующие Swagger-контракт: - -```python -response_model=... -request_model=... -request_schema=... -response_schema=... -success_statuses=... -error_statuses=... -content_type=... -required_fields=... -query_params=... -path_params=... -``` - -Причина: это создает второй источник истины и допускает расхождение со Swagger. - -## 5. Path Expressions - -`factory_args` и `method_args` описывают, как contract-test runner строит SDK-вызов из Swagger-generated request data. - -Разрешенные выражения: - -```text -path. path parameter -query. query parameter -header. header parameter -body весь request body -body. поле request body -constant. тестовая константа из controlled test registry -``` - -Примеры: - -```python -factory_args={ - "user_id": "path.user_id", - "item_id": "path.item_id", -} -``` - -```python -method_args={ - "request": "body", -} -``` - -```python -method_args={ - "limit": "query.limit", - "offset": "query.offset", -} -``` - -Ограничения: - -- `factory_args` и `method_args` не должны содержать Python expressions. -- Запрещены произвольные callables. -- Запрещены dotted paths, которые не относятся к Swagger request. -- `constant.*` разрешается только для заранее зарегистрированных тестовых констант. - -## 6. Swagger Operation Identity - -Операция определяется ключом: - -```text -spec + method + normalized_path -``` - -Если `spec` не указан, operation может быть найдена по: - -```text -method + normalized_path -``` - -только если совпадение среди всех Swagger-файлов ровно одно. - -`operation_id`, если указан, является дополнительной проверкой, а не основным источником истины. - -## 7. Линтер - -Нужен отдельный CLI-линтер: - -```bash -poetry run python scripts/lint_swagger_bindings.py -``` - -И make target: - -```bash -make swagger-lint -``` - -Линтер должен запускаться вместе с общей проверкой качества проекта. - -## 8. Что Проверяет Линтер - -### 8.1 Swagger Files - -Линтер загружает все файлы: - -```text -docs/avito/api/*.json -``` - -Проверяет: - -- JSON валиден; -- Swagger/OpenAPI структура поддерживается; -- все paths и operations извлекаются; -- каждая operation имеет стабильный ключ; -- нет дублей `spec + method + path`; -- path parameters в path совпадают с parameters/request definition. - -### 8.2 SDK Binding Discovery - -Линтер импортирует пакет `avito` и находит все функции/методы с: - -```python -__swagger_binding__ -``` - -Для каждого binding определяет: - -- module; -- class name; -- method name; -- effective `spec`; -- effective `factory`; -- effective `factory_args`; -- effective `method_args`; -- class-level metadata. - -### 8.3 Completeness - -Обязательные проверки: - -```text -1. Каждая Swagger operation имеет ровно один SDK binding. -2. Каждый SDK binding указывает на существующую Swagger operation. -3. Две SDK methods не могут ссылаться на одну Swagger operation. -4. Один SDK method не может иметь несколько bindings, кроме явно разрешенной политики. -5. spec из binding/class metadata должен существовать в docs/avito/api/. -6. method/path должны совпадать с operation из Swagger. -7. operation_id, если указан, должен совпадать со Swagger. -``` - -### 8.4 Deprecated / Legacy - -Проверки: - -```text -1. Если Swagger operation deprecated=true, binding должен иметь deprecated=True. -2. Если binding deprecated=True, Swagger operation тоже должна быть deprecated. -3. Если политика SDK требует legacy domain для deprecated operations, binding должен иметь legacy=True. -4. Non-deprecated operation не может иметь legacy=True без явного исключения. -``` - -Исключения, если они понадобятся, должны быть описаны в отдельном allowlist-файле с причиной и датой удаления. По умолчанию allowlist запрещен. - -### 8.5 Factory Validation - -Для каждого binding: - -```text -1. factory должен существовать на AvitoClient. -2. Если factory не указан в decorator, должен быть __sdk_factory__ на классе. -3. factory_args должны соответствовать сигнатуре factory. -4. method_args должны соответствовать сигнатуре decorated SDK method. -5. Required параметры factory/method должны быть покрыты mapping-ом. -6. В mapping не должно быть лишних аргументов. -``` - -### 8.6 Path Expression Validation - -Для каждого выражения в `factory_args` и `method_args`: - -```text -path. - должен существовать среди path parameters Swagger operation. - -query. - должен существовать среди query parameters Swagger operation. - -header. - должен существовать среди header parameters Swagger operation. - -body - Swagger operation должна иметь request body. - -body. - Swagger operation должна иметь request body schema, где поле существует. - -constant. - должен существовать в test constants registry. -``` - -### 8.7 Запрет Дублирования Контракта - -Линтер должен падать, если в `SwaggerOperationBinding` или decorator call появляются запрещенные поля: - -- statuses; -- schemas; -- content types; -- response models; -- request models; -- error models. - -Это можно проверять через сигнатуру декоратора и unit-тесты самого декоратора. - -## 9. Формат Ошибок Линтера - -Ошибка должна быть точной и actionable. - -Пример: - -```text -[SWAGGER_BINDING_NOT_FOUND] -Swagger operation has no SDK binding: -spec=Мессенджер.json -method=GET -path=/messenger/v1/accounts/{user_id}/chats/{chat_id} -``` - -```text -[SWAGGER_BINDING_DUPLICATE] -Multiple SDK methods bind the same Swagger operation: -spec=Объявления.json -method=GET -path=/core/v1/items/{item_id} -methods: -- avito.ads.domain.Ad.get -- avito.ads.client.AdsClient.get_item -``` - -```text -[SWAGGER_ARG_UNKNOWN_QUERY_PARAM] -Binding references unknown query parameter: -method=avito.messenger.domain.Chat.list -expression=query.page -swagger_operation=GET /messenger/v1/accounts/{user_id}/chats -known_query_params=[limit, offset] -``` - -## 10. Не Цель Декоратора - -Декоратор не должен: - -- генерировать SDK-код; -- валидировать реальные payload на runtime; -- выполнять HTTP; -- знать response statuses; -- знать schemas; -- заменять Swagger; -- заменять typed models. - -Runtime/request/response проверки делает `SwaggerFakeTransport` и contract tests, используя Swagger operation, найденную через binding. - -## Итоговый Инвариант - -```text -Swagger operation -↔ exactly one @swagger_operation SDK method -→ SwaggerFakeTransport validates actual HTTP request/response -→ contract tests validate all statuses and errors from Swagger -``` - -Декоратор дает строгую адресацию. Линтер гарантирует, что адресация полная и валидная. Swagger остается единственным источником API-контракта. diff --git a/uv.lock b/uv.lock deleted file mode 100644 index a5bc514..0000000 --- a/uv.lock +++ /dev/null @@ -1,3 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.14"