diff --git a/.ai/python-guidelines.md b/.ai/python-guidelines.md new file mode 100644 index 0000000..81df633 --- /dev/null +++ b/.ai/python-guidelines.md @@ -0,0 +1,404 @@ +# Python Guidelines + +Rules and conventions for Python code in this repository. + +## Critical Rules + +These are the conventions that matter most for code quality and maintainability. +Exceptions exist, but they should be rare and justified in a code comment. + +### Keep imports at the top of the file + +**Always flag** any `import` statement that appears inside a function body, method, +or class. Imports inside functions hide dependencies, make the module harder to +understand at a glance, and can mask missing packages until a specific code path +is hit at runtime. + +**Always flag** `try/except ImportError` around imports (except for the documented +exceptions below). This pattern creates two execution modes -- one with the library +and one without -- which doubles the testing surface and produces confusing +behavior when the dependency is unexpectedly absent. + +```python +# BAD -- import inside function; dependency is invisible until this path runs +# ALWAYS FLAG THIS PATTERN +def process_data(): + import json + return json.loads(data) + +# BAD -- try/except hides a missing dependency behind a flag +try: + import requests + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + +# GOOD -- direct import at module top; fails immediately if missing +import json +import requests +``` + +Failing immediately on a missing dependency is better than hiding the problem +until a user hits an obscure code path in production. If a package is required, +add it to `requirements.txt` or `pyproject.toml` so it's installed upfront. + +**Exception: optional dependencies and pytest collection.** This repo has multiple +optional backends (vLLM, SGLang, TRT-LLM, cupy, etc.) that are not installed in +every environment. Using `try/except ImportError` is the correct pattern when: + +- An optional backend dependency (e.g. `tritonclient.grpc`, `vllm_omni`, + `torch_memory_saver`) may not be installed, and the code provides a + fallback or sets the import to `None`. +- A test file needs to skip collection when optional packages are absent + (e.g. `except ImportError: pytest.skip(..., allow_module_level=True)`). +- A stdlib module has version-dependent availability + (e.g. `tomllib` on Python 3.11+ vs `tomli` fallback). + +```python +# OK -- optional backend, graceful fallback to None +try: + from vllm_omni.diffusion.data import DiffusionParallelConfig +except ImportError: + DiffusionParallelConfig = None + +# OK -- skip test collection when optional deps are missing +try: + from dynamo.profiler.rapid import WorkloadSpec +except ImportError as e: + pytest.skip(f"Skip (missing dependency): {e}", allow_module_level=True) + +# OK -- stdlib version compatibility +try: + import tomllib +except ImportError: + import tomli as tomllib +``` + +### Prefer failing fast over hiding errors + +From PEP 20 (The Zen of Python): + +> *"Errors should never pass silently. Unless explicitly silenced."* +> *"Explicit is better than implicit."* + +Failing immediately when something goes wrong is better than silently continuing +with bad state, because: + +- The person who caused the error sees it right away, while the context is fresh. +- The stack trace points directly at the root cause, not at a downstream symptom + three function calls later. +- Hidden errors compound -- a swallowed exception in one place produces confusing + behavior somewhere else, and the debugging cost grows exponentially with distance + from the original failure. + +```python +# BAD -- all of these hide errors +except Exception: + pass + +except Exception as e: + logging.error(e) # logs but silently continues! + return [] # returns a fake default + +# BAD -- bare except catches KeyboardInterrupt and SystemExit too, +# making the process impossible to kill with Ctrl-C or sys.exit() +try: + do_work() +except: + log_error() + +# GOOD -- just let it crash +result = something() + +# GOOD -- catch SPECIFIC exceptions you can actually handle +try: + result = json.loads(text) +except json.JSONDecodeError: + result = {} + +# GOOD -- if you must catch broad, catch Exception (not bare except) +# and re-raise after logging +try: + result = something() +except Exception as e: + logger.error(f"Failed: {e}") + raise +``` + +**Three rules:** +1. Remove the try/except if possible -- let it crash. +2. Catch **specific** exceptions only (`FileNotFoundError`, `ValueError`, `json.JSONDecodeError`, etc.). +3. If you must catch broadly, use `except Exception:` (never bare `except:`) and **always** re-raise after logging. + +### NO defensive `getattr()` on known types + +**Always flag** `getattr(obj, "attr", default)` when the object's type is known +and the attribute is part of its definition (class attribute, `__init__` parameter, +dataclass field, etc.). Using `getattr()` with a default hides bugs by silently +returning a fallback when the attribute should always exist. Direct attribute +access fails loudly if the type contract changes, which is what you want. + +```python +# BAD -- cfg is a ServiceConfig with host/port; getattr hides AttributeError +# ALWAYS FLAG THIS PATTERN +cfg = ServiceConfig(host="0.0.0.0", port=8080) +host = getattr(cfg, "host", "localhost") +port = getattr(cfg, "port", 9999) + +# GOOD -- direct access, fails loudly if something is wrong +host = cfg.host +port = cfg.port +``` + +--- + +## Anti-Patterns That Must Be Flagged in Review + +Every item below is a **mandatory review check**. If any of these patterns appear +in a pull request, flag it and request changes. These are not style preferences -- +they are sources of real bugs, resource leaks, and CI flakiness. + +### Mutable default arguments + +**Always flag** any function whose default argument is a mutable object (`[]`, `{}`, +`set()`). Default values are evaluated once at function definition time and shared +across all calls, so mutations accumulate silently between invocations. + +```python +# BAD -- the list is shared across all calls; flag this +def add_item(item, items=[]): + items.append(item) + return items + +add_item("a") # ["a"] +add_item("b") # ["a", "b"] -- not ["b"]! + +# GOOD -- use None sentinel, create a new list each call +def add_item(item, items=None): + if items is None: + items = [] + items.append(item) + return items +``` + +### Leaked file handles -- always use context managers + +**Always flag** any `open()` call that is not wrapped in a `with` statement. +Files, network connections, subprocesses, and locks must be opened with `with` +so they are released even if an exception occurs. Bare `open()` followed by +manual `.close()` leaks the handle when an exception fires between the two calls. + +```python +# BAD -- file handle leaks if json.load raises; flag this +f = open("data.json") +data = json.load(f) +f.close() + +# GOOD +with open("data.json") as f: + data = json.load(f) +``` + +This is especially important in this project where tests manage subprocesses, +etcd/NATS connections, and temp directories. Use `ManagedProcess`, +`tempfile.TemporaryDirectory`, and similar context managers rather than +manual setup/teardown. + +### Shadowing built-in names + +**Always flag** any variable named `list`, `dict`, `id`, `type`, `input`, `open`, +`format`, `set`, `map`, `filter`, `range`, `str`, `int`, `float`, `bool`, `bytes`, +`tuple`, `hash`, `len`, `min`, `max`, `sum`, `any`, `all`, `zip`, `enumerate`, +`sorted`, `reversed`, or `next`. Assigning to these names overwrites the built-in +and causes confusing `TypeError`s later in the same scope. + +```python +# BAD -- shadows built-in list(); flag this +list = get_items() +filtered = list(some_gen) # TypeError: 'list' object is not callable + +# GOOD +items = get_items() +filtered = list(some_gen) +``` + +### Use `is` for None / True / False comparisons + +**Always flag** `== None`, `== True`, `== False`, `!= None`, `!= True`, and +`!= False`. These invoke `__eq__`, which can be overridden and produce +surprising results. Use `is` / `is not` for singleton comparisons. + +```python +# BAD -- flag these +if result == None: +if flag == True: +if done == False: + +# GOOD +if result is None: +if flag is True: # or just: if flag: +if not done: +``` + +### Do not modify a collection while iterating + +**Always flag** any loop that adds, removes, or deletes from the collection it is +iterating over. This causes skipped elements, `RuntimeError` (for dicts), or +infinite loops. Build a new collection or iterate over a copy. + +```python +# BAD -- RuntimeError on dict, skips elements on list; flag this +for item in items: + if item.is_stale(): + items.remove(item) + +# GOOD +items = [item for item in items if not item.is_stale()] +``` + +### Prefer `join()` over string concatenation in loops + +**Always flag** `+=` on a string variable inside a loop. Repeated `+=` on strings +creates a new string object each time, which is O(n^2) for large loops. + +```python +# BAD -- O(n^2) string building; flag this +result = "" +for line in lines: + result += line + "\n" + +# GOOD -- O(n) with join +result = "\n".join(lines) +``` + +### Late-binding closures in loops + +**Always flag** lambdas or inner functions created inside a loop that reference the +loop variable without binding it as a default argument. Closures capture the +variable reference, not its value at the time of creation, so all closures end up +with the final loop value. + +```python +# BAD -- all lambdas return 4 (the final value of i); flag this +fns = [lambda: i for i in range(5)] +[f() for f in fns] # [4, 4, 4, 4, 4] + +# GOOD -- default argument captures current value +fns = [lambda i=i: i for i in range(5)] +[f() for f in fns] # [0, 1, 2, 3, 4] +``` + +### Do not use `assert` for runtime validation + +**Always flag** `assert` statements used to validate function arguments, request +payloads, configuration, or any data that comes from outside the current function. +Assertions are stripped when Python runs with `-O` (optimize), silently removing +the validation. Use explicit `if/raise` for checks that must always execute. + +```python +# BAD -- silently skipped under python -O; flag this +assert user_id is not None, "user_id required" + +# GOOD +if user_id is None: + raise ValueError("user_id required") +``` + +--- + +## Code Style + +- Follow PEP 8. +- `snake_case` for variables and functions. +- `PascalCase` for classes. +- Add type hints where they improve readability. +- Use docstrings for public functions and classes. +- Use `dataclass` instead of plain dicts when a structure has >4 fields (better + type inference and IDE support). + +## File Organization + +- `__init__.py` for package initialization. +- Clear module separation. +- Tests in `tests/` directory. + +## Formatting and Linting + +### Preferred workflow + +Auto-fix formatting, then lint: + +```bash +ruff format +ruff check --fix +``` + +Or use pre-commit (runs isort, black, flake8, ruff, and more): + +```bash +pre-commit run --files +pre-commit run --all-files # for broad changes +``` + +### Pre-commit hooks + +The repo's `.pre-commit-config.yaml` runs these Python hooks: + +- **isort** -- import sorting (`profile = "black"`, configured in `pyproject.toml`) +- **black** -- code formatting +- **flake8** -- style checks (`max-line-length=88`) +- **ruff** -- fast linting with auto-fix +- **codespell** -- spelling checks +- **trailing-whitespace**, **end-of-file-fixer**, **check-yaml**, **check-json**, **check-toml** + +### Before committing + +Always run: + +```bash +pre-commit run --files +``` + +For broader changes or if in doubt: + +```bash +pre-commit run --all-files +``` + +### Indentation verification + +Indentation errors are common and hard to spot visually. After editing Python +files, verify mechanically: + +```bash +ruff format # auto-fix (preferred) +python3 -m compileall -q # fast parse-only check +``` + +When fixing indentation errors, always read 20-30 lines of surrounding context +and fix the whole block -- adjacent lines often share the same mistake. + +## Error Handling + +See the **Critical Rules** section above for the full policy. Summary: + +- Let exceptions propagate by default. +- Catch only specific exceptions you can actually handle. +- If you catch `Exception`, you must re-raise after logging. +- No `except Exception: pass`. Ever. + +### Regex caution + +Be careful with escaping in raw strings (`\s` vs `\\s`). When changing a critical +regex, add a one-line test to prove it matches. + +## Import Order + +Imports are sorted by isort with `profile = "black"` (configured in `pyproject.toml`). +The order is: + +1. Standard library +2. Third-party (known: `vllm`, `tensorrt_llm`, `sglang`, `aiconfigurator`) +3. First-party (`dynamo`, `deploy`) + +Run `isort` or `pre-commit run isort` to auto-fix ordering diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2277b12..a27daf9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -53,7 +53,13 @@ "Bash(grep -E \"\\\\.json$|\\\\.lock$|todo\\\\.md|action_plan\\\\.md|usability_scorecard\\\\.md\")", "Bash(echo \"---EXISTS:$?\")", "Bash(git mv *)", - "Bash(make swagger-lint *)" + "Bash(make swagger-lint *)", + "Bash(git -C /Users/n.baryshnikov/Projects/avito_python_api branch -a)", + "Bash(git -C /Users/n.baryshnikov/Projects/avito_python_api log --oneline async..HEAD)", + "Bash(grep -v \":0$\")", + "Bash(grep -E \"\\\\\\\\.py$\")", + "Bash(git -C /Users/n.baryshnikov/Projects/avito_python_api log --oneline async..main)", + "Bash(git -C /Users/n.baryshnikov/Projects/avito_python_api log --oneline main..async)" ] } } diff --git a/CHANGELOG.d/.gitkeep b/CHANGELOG.d/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/CHANGELOG.d/.gitkeep @@ -0,0 +1 @@ + diff --git a/CHANGELOG.d/README.md b/CHANGELOG.d/README.md new file mode 100644 index 0000000..3fdbf8f --- /dev/null +++ b/CHANGELOG.d/README.md @@ -0,0 +1,12 @@ +# Changelog fragments + +Domain async PRs after M1 add one fragment named `-async-.md`. + +Supported format: + +```markdown +### Added +- Async-поддержка домена : Async, Async (#) +``` + +M-final aggregates fragments into `CHANGELOG.md` under `## [2.1.0]`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 01b5468..02b078d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ and this project adheres to Semantic Versioning. ## [Unreleased] ### Added +- Нет изменений. + +## [2.1.0] - 2026-05-08 + +### Added +- Фундамент Async API: `AsyncTransport`, `AsyncAuthProvider`, + `AsyncOperationExecutor`, `AsyncPaginatedList`, `AsyncAvitoClient` без + доменных factory-методов; `RateLimitState` вынесен в shared. +- Async-поддержка домена tariffs: `AsyncTariff` (PoC шаблона). +- Async-поддержка домена accounts: AsyncAccount, AsyncAccountHierarchy. +- Async-поддержка домена ads: AsyncAd, AsyncAdStats, AsyncAdPromotion, AsyncAutoloadProfile, AsyncAutoloadReport, AsyncAutoloadArchive. +- Async-поддержка домена autoteka: AsyncAutotekaVehicle, AsyncAutotekaReport, AsyncAutotekaMonitoring, AsyncAutotekaScoring, AsyncAutotekaValuation. +- Async-поддержка домена cpa: AsyncCallTrackingCall, AsyncCpaArchive, AsyncCpaCall, AsyncCpaChat, AsyncCpaLead. +- Async-поддержка домена jobs: AsyncApplication, AsyncJobDictionary, AsyncJobWebhook, AsyncResume, AsyncVacancy. +- Async-поддержка домена messenger: AsyncChat, AsyncChatMedia, AsyncChatMessage, AsyncChatWebhook, AsyncSpecialOfferCampaign. +- Async-поддержка домена orders: AsyncOrder, AsyncOrderLabel, AsyncDeliveryOrder, AsyncSandboxDelivery, AsyncDeliveryTask, AsyncStock. +- Async-поддержка домена promotion: AsyncPromotionOrder, AsyncBbipPromotion, AsyncTrxPromotion, AsyncCpaAuction, AsyncTargetActionPricing, AsyncAutostrategyCampaign. +- Async-поддержка домена ratings: AsyncRatingProfile, AsyncReview, AsyncReviewAnswer. +- Async-поддержка домена realty: AsyncRealtyAnalyticsReport, AsyncRealtyBooking, AsyncRealtyListing, AsyncRealtyPricing. +- Async convenience methods для `AsyncAvitoClient`: `business_summary`, `account_health`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`, `promotion_summary`, `capabilities`. - Добавлен `ClientClosedError` для вызовов после `AvitoClient.close()`. ### Deprecated diff --git a/Makefile b/Makefile index 7171da2..0c65698 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ MKDOCS_ENV=DISABLE_MKDOCS_2_WARNING=true NO_MKDOCS_2_WARNING=1 check: test quality -quality: typecheck lint swagger-lint architecture-lint docstring-lint build +quality: typecheck lint swagger-lint architecture-lint async-parity-lint docstring-lint build build: clean poetry build @@ -41,6 +41,9 @@ swagger-lint: architecture-lint: poetry run python scripts/lint_architecture.py +async-parity-lint: + poetry run python scripts/lint_async_parity.py + docstring-lint: poetry run python scripts/lint_docstrings.py diff --git a/README.md b/README.md index bcab888..afc2ae5 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ print(ad.title) По умолчанию настройки читаются из переменных окружения с префиксом `AVITO_`. -`avito-py` — синхронный Python SDK для работы с Avito API через единый объектный фасад `AvitoClient`. +`avito-py` — Python SDK для работы с Avito API через единые sync/async фасады +`AvitoClient` и `AsyncAvitoClient`. ## Установка @@ -84,6 +85,19 @@ with AvitoClient(settings) as avito: Все опциональные параметры конструктора — keyword-only. `AvitoClient` иммутабелен: `base_url`, таймауты, retry-политика и `auth` не меняются у живого клиента — вместо этого создаётся новый клиент. +Async-поверхность использует те же доменные методы и модели, но требует `async with`: + +```python +from avito import AsyncAvitoClient + +async with AsyncAvitoClient.from_env() as avito: + profile = await avito.account().get_self() + listings = await (await avito.ad(user_id=123).list(limit=20)).materialize() +``` + +Подробный контракт async lifecycle, ASGI-рецепты и ограничения описаны в +[async how-to](https://p141592.github.io/avito_python_api/how-to/async/). + ### Переменные окружения | Переменная | Обязательная | Описание | diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index afd0631..b7f110d 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -65,6 +65,7 @@ avito/ ratings/ __init__.py domain.py + async_domain.py operations.py models.py ``` @@ -96,7 +97,8 @@ Rules: - Each API section lives in its own package: `ads`, `messenger`, `orders`, `autoload`, etc. - Only modules belonging to that section are allowed inside each section package. - `avito/client.py` and `avito/__init__.py` contain only the high-level entry point and public exports. -- `domain.py` contains public `DomainObject` classes, explicit public methods, reference-ready docstrings, `@swagger_operation(...)` bindings, business validation, and construction of internal request models. +- `domain.py` contains public sync `DomainObject` classes, explicit public methods, reference-ready docstrings, `@swagger_operation(..., variant="sync")` bindings, business validation, and construction of internal request models. +- `async_domain.py` is the allowed async companion for ported domains. It contains `AsyncDomainObject` classes named `Async` and mirrors the sync public methods with `async def`, `AsyncPaginatedList[T]` where sync returns `PaginatedList[T]`, and `@swagger_operation(..., variant="async")`. - `operations.py` or `operations/` contains internal `OperationSpec` definitions: HTTP method, path, operation name, retry policy, path rendering, request model class, response model class, and pagination/binary/multipart strategy when applicable. - `models.py` or `models/` contains public response dataclasses, internal request/query dataclasses, colocated enum types, `from_payload()`, `to_payload()`, `to_params()`, and normalization logic. - API domains must not introduce `client.py`, `mappers.py`, or standalone @@ -372,8 +374,8 @@ Rules: Recommendation: -- Build a high-quality sync SDK first. -- The SDK is synchronous — this must be explicitly documented in the README and public API. +- Build a high-quality dual-mode SDK: sync remains the default stable surface, and async is exposed through explicit `Async*` classes. +- The SDK has separate sync and async public surfaces. Async code must not wrap sync network calls; it uses `httpx.AsyncClient`, `AsyncTransport`, `AsyncAuthProvider`, `AsyncOperationExecutor`, and async pagination primitives. ### User-Agent and Client Identification @@ -883,7 +885,7 @@ Rules: - Swagger bindings must not duplicate the API contract. Decorators and binding metadata must not contain request/response schemas, status lists, content types, response models, request models, error models, required fields, path parameter definitions, or query parameter definitions. - Public domain classes that expose bound methods should declare class-level metadata (`__swagger_domain__`, `__swagger_spec__`, `__sdk_factory__`, and when needed `__sdk_factory_args__`) so discovery can resolve bindings without creating `AvitoClient`, reading required environment variables, or doing network work. - The canonical coverage map is generated from Swagger registry plus discovered `@swagger_operation` bindings. Markdown inventory files and hand-written coverage tables must not be used as source of truth. -- Each Swagger operation must resolve to exactly one discovered binding in strict mode. One public SDK method must not have more than one Swagger binding. Stacked `@swagger_operation(...)` decorators and `__swagger_bindings__` metadata are forbidden. +- Each Swagger operation must resolve to exactly one discovered binding per surface variant in strict mode: one `sync` binding and, for ported async classes, one `async` binding. One public SDK method must not have more than one Swagger binding. Stacked `@swagger_operation(...)` decorators and `__swagger_bindings__` metadata are forbidden. - Public method signatures, model field names and types, allowed enum values, and nullable behavior must exactly match the contract in `docs/avito/api/`. - When there is a discrepancy between code and the specification in `docs/avito/api/`, the specification takes priority. - If the upstream API adds a new endpoint or changes an existing one, a corresponding SDK change is mandatory. diff --git a/avito/__init__.py b/avito/__init__.py index d54fa85..0f4ae26 100644 --- a/avito/__init__.py +++ b/avito/__init__.py @@ -1,8 +1,10 @@ """Публичные экспорты пакета SDK для Avito.""" +from avito.async_client import AsyncAvitoClient from avito.auth.settings import AuthSettings from avito.client import AvitoClient from avito.config import AvitoSettings +from avito.core.async_pagination import AsyncPaginatedList from avito.core.exceptions import ( AuthenticationError, AuthorizationError, @@ -34,6 +36,8 @@ __all__ = ( "AccountHealthSummary", "AuthSettings", + "AsyncAvitoClient", + "AsyncPaginatedList", "AuthenticationError", "AuthorizationError", "AvitoClient", diff --git a/avito/accounts/__init__.py b/avito/accounts/__init__.py index d4fd45f..2ff9418 100644 --- a/avito/accounts/__init__.py +++ b/avito/accounts/__init__.py @@ -1,5 +1,6 @@ """Пакет accounts.""" +from avito.accounts.async_domain import AsyncAccount, AsyncAccountHierarchy from avito.accounts.domain import Account, AccountHierarchy from avito.accounts.models import ( AccountActionResult, @@ -26,6 +27,8 @@ "AccountHierarchyRole", "AccountProfile", "AhUserStatus", + "AsyncAccount", + "AsyncAccountHierarchy", "CompanyPhone", "CompanyPhonesResult", "Employee", diff --git a/avito/accounts/async_domain.py b/avito/accounts/async_domain.py new file mode 100644 index 0000000..dd8fa4f --- /dev/null +++ b/avito/accounts/async_domain.py @@ -0,0 +1,401 @@ +"""Async-доменные объекты пакета accounts.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime + +from avito.accounts.models import ( + AccountActionResult, + AccountBalance, + AccountProfile, + AhUserStatus, + CompanyPhonesResult, + EmployeeItem, + EmployeeItemLinkRequest, + EmployeeItemsRequest, + EmployeesResult, + OperationRecord, + OperationsHistoryRequest, +) +from avito.accounts.operations import ( + GET_AH_USER_STATUS, + GET_BALANCE, + GET_OPERATIONS_HISTORY, + GET_SELF, + LINK_ITEMS, + LIST_COMPANY_PHONES, + LIST_EMPLOYEES, + LIST_ITEMS_BY_EMPLOYEE, +) +from avito.core import ( + ApiTimeouts, + AsyncPaginatedList, + AsyncPaginator, + JsonPage, + RetryOverride, + ValidationError, +) +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation + + +def _serialize_datetime(value: datetime) -> str: + """Serialize datetime.""" + return value.isoformat() + + +@dataclass(slots=True, frozen=True) +class AsyncAccount(AsyncDomainObject): + """Async-доменный объект операций аккаунта.""" + + __swagger_domain__ = "accounts" + __sdk_factory__ = "account" + __sdk_factory_args__ = {"user_id": "path.user_id"} + + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/core/v1/accounts/self", + spec="Информацияопользователе.json", + operation_id="getUserInfoSelf", + variant="async", + ) + async def get_self( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AccountProfile: + """Получает профиль авторизованного пользователя асинхронно. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AccountProfile` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_SELF, timeout=timeout, retry=retry) + + @swagger_operation( + "GET", + "/core/v1/accounts/{user_id}/balance", + spec="Информацияопользователе.json", + operation_id="getUserBalance", + variant="async", + ) + async def get_balance( + self, + *, + user_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AccountBalance: + """Получает баланс пользователя по явно заданному или настроенному `user_id` асинхронно. + + Аргументы: + user_id: идентификатор пользователя; если не передан, используется `user_id` фабрики, `AVITO_USER_ID` или `get_self()`. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AccountBalance` с реальным, бонусным и суммарным балансом. + + Поведение: + `user_id` является keyword-only, чтобы вызов явно показывал источник аккаунта. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_user_id = await self._resolve_account_user_id(user_id) + return await self._execute( + GET_BALANCE, + path_params={"user_id": resolved_user_id}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/core/v1/accounts/operations_history", + spec="Информацияопользователе.json", + operation_id="postOperationsHistory", + method_args={"date_from": "body.dateTimeFrom", "date_to": "body.dateTimeTo"}, + variant="async", + ) + async def get_operations_history( + self, + *, + date_from: datetime, + date_to: datetime, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AsyncPaginatedList[OperationRecord]: + """Возвращает историю операций аккаунта за выбранный период асинхронно. + + Аргументы: + date_from: задает начальную дату периода. + date_to: задает конечную дату периода. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + Ленивый `AsyncPaginatedList[OperationRecord]`; первая страница загружается при создании, следующие страницы - при async-итерации. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + async def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[OperationRecord]: + """Fetch one page of results.""" + result = await self._execute( + GET_OPERATIONS_HISTORY, + request=OperationsHistoryRequest( + date_from=_serialize_datetime(date_from), + date_to=_serialize_datetime(date_to), + ), + timeout=timeout, + retry=retry, + ) + return JsonPage( + items=result.operations, + total=result.total, + ) + + return AsyncPaginator(fetch_page).as_list(first_page=await fetch_page(1, None)) + + async def _resolve_account_user_id(self, user_id: int | None) -> int: + """Resolve account user id.""" + if user_id is not None or self.user_id is not None: + return await self._resolve_user_id(user_id or self.user_id) + profile = await self.get_self() + if profile.user_id is None: + raise ValidationError( + "Для операции требуется `user_id`: передайте его в фабрику клиента, " + "в метод операции или задайте `AVITO_USER_ID`." + ) + return profile.user_id + + +@dataclass(slots=True, frozen=True) +class AsyncAccountHierarchy(AsyncDomainObject): + """Async-доменный объект иерархии аккаунтов.""" + + __swagger_domain__ = "accounts" + __sdk_factory__ = "account_hierarchy" + __sdk_factory_args__ = {"user_id": "path.user_id"} + + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/checkAhUserV1", + spec="ИерархияАккаунтов.json", + operation_id="checkAhUserV1", + variant="async", + ) + async def get_status( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AhUserStatus: + """Получает статус пользователя в ИА асинхронно. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AhUserStatus` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_AH_USER_STATUS, timeout=timeout, retry=retry) + + @swagger_operation( + "GET", + "/getEmployeesV1", + spec="ИерархияАккаунтов.json", + operation_id="getEmployeesV1", + variant="async", + ) + async def list_employees( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> EmployeesResult: + """Возвращает сотрудников компании в иерархии аккаунта асинхронно. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `EmployeesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(LIST_EMPLOYEES, timeout=timeout, retry=retry) + + @swagger_operation( + "GET", + "/listCompanyPhonesV1", + spec="ИерархияАккаунтов.json", + operation_id="listCompanyPhonesV1", + variant="async", + ) + async def list_company_phones( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> CompanyPhonesResult: + """Возвращает телефоны компании из иерархии аккаунта асинхронно. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CompanyPhonesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(LIST_COMPANY_PHONES, timeout=timeout, retry=retry) + + @swagger_operation( + "POST", + "/linkItemsV1", + spec="ИерархияАккаунтов.json", + operation_id="linkItemsV1", + method_args={"employee_id": "body.employee_id", "item_ids": "body.item_ids"}, + variant="async", + ) + async def link_items( + self, + *, + employee_id: int, + item_ids: Sequence[int], + source_employee_id: int | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AccountActionResult: + """Прикрепляет объявления к сотруднику асинхронно. + + Аргументы: + employee_id: идентификатор сотрудника, к которому прикрепляются объявления. + item_ids: список идентификаторов объявлений. + source_employee_id: идентификатор сотрудника-источника, если объявления переносятся между сотрудниками. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AccountActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + LINK_ITEMS, + request=EmployeeItemLinkRequest( + employee_id=employee_id, + item_ids=list(item_ids), + source_employee_id=source_employee_id, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/listItemsByEmployeeIdV1", + spec="ИерархияАккаунтов.json", + operation_id="listItemsByEmployeeIdV1", + method_args={ + "employee_id": "body.employee_id", + "category_id": "body.category_id", + }, + variant="async", + ) + async def list_items_by_employee( + self, + *, + employee_id: int, + category_id: int, + last_item_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AsyncPaginatedList[EmployeeItem]: + """Возвращает объявления, закрепленные за сотрудником компании, асинхронно. + + Аргументы: + employee_id: идентифицирует сотрудника аккаунта. + category_id: ограничивает объявления категорией из справочника Авито. + last_item_id: задает курсор для продолжения выборки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + Ленивый `AsyncPaginatedList[EmployeeItem]`; первая страница загружается при создании, следующие страницы - при async-итерации. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + async def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[EmployeeItem]: + """Fetch one page of results.""" + current_page = page or 1 + result = await self._execute( + LIST_ITEMS_BY_EMPLOYEE, + request=EmployeeItemsRequest( + employee_id=employee_id, + category_id=category_id, + last_item_id=last_item_id, + ), + timeout=timeout, + retry=retry, + ) + return JsonPage( + items=result.items, + total=result.total, + page=current_page, + per_page=len(result.items), + ) + + return AsyncPaginator(fetch_page).as_list(first_page=await fetch_page(1, None)) + + +__all__ = ("AsyncAccount", "AsyncAccountHierarchy") diff --git a/avito/ads/__init__.py b/avito/ads/__init__.py index 1318d22..e1dcde9 100644 --- a/avito/ads/__init__.py +++ b/avito/ads/__init__.py @@ -1,5 +1,13 @@ """Пакет ads.""" +from avito.ads.async_domain import ( + AsyncAd, + AsyncAdPromotion, + AsyncAdStats, + AsyncAutoloadArchive, + AsyncAutoloadProfile, + AsyncAutoloadReport, +) from avito.ads.domain import ( Ad, AdPromotion, @@ -57,6 +65,12 @@ "AdsActionStatus", "AdPromotion", "AdStats", + "AsyncAd", + "AsyncAdPromotion", + "AsyncAdStats", + "AsyncAutoloadArchive", + "AsyncAutoloadProfile", + "AsyncAutoloadReport", "AutoloadArchive", "AutoloadAvitoStatus", "AutoloadFieldType", diff --git a/avito/ads/async_domain.py b/avito/ads/async_domain.py new file mode 100644 index 0000000..dd6987c --- /dev/null +++ b/avito/ads/async_domain.py @@ -0,0 +1,1648 @@ +"""Async-доменные объекты пакета ads.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from avito.ads.models import ( + AccountSpendings, + AdAnalyticsGroupingInput, + AdsActionResult, + AdSpendingsGroupingInput, + ApplyVasDirectRequest, + ApplyVasPackageRequest, + ApplyVasRequest, + AutoloadFeesResult, + AutoloadFieldsResult, + AutoloadProfileSettings, + AutoloadProfileUpdateRequest, + AutoloadReportDetails, + AutoloadReportItemsResult, + AutoloadReportSummary, + AutoloadTreeResult, + CallsStatsRequest, + CallsStatsResult, + IdMappingResult, + ItemAnalyticsRequest, + ItemAnalyticsResult, + ItemStatsRequest, + ItemStatsResult, + LegacyAutoloadReport, + Listing, + ListingStatus, + SpendingsRequest, + UpdatePriceRequest, + UpdatePriceResult, + UploadByUrlRequest, + UploadResult, + VasPricesRequest, + VasPricesResult, +) +from avito.ads.operations import ( + APPLY_ITEM_VAS, + APPLY_ITEM_VAS_PACKAGE, + APPLY_VAS_DIRECT, + GET_ACCOUNT_SPENDINGS, + GET_AD_IDS_BY_AVITO_IDS, + GET_ARCHIVE_LAST_COMPLETED_REPORT, + GET_ARCHIVE_PROFILE, + GET_ARCHIVE_REPORT, + GET_AUTOLOAD_ITEMS_INFO, + GET_AUTOLOAD_LAST_COMPLETED_REPORT, + GET_AUTOLOAD_NODE_FIELDS, + GET_AUTOLOAD_PROFILE, + GET_AUTOLOAD_REPORT, + GET_AUTOLOAD_REPORT_FEES, + GET_AUTOLOAD_REPORT_ITEMS, + GET_AUTOLOAD_TREE, + GET_AVITO_IDS_BY_AD_IDS, + GET_CALLS_STATS, + GET_ITEM, + GET_ITEM_ANALYTICS, + GET_ITEM_STATS, + GET_VAS_PRICES, + LIST_AUTOLOAD_REPORTS, + LIST_ITEMS, + SAVE_ARCHIVE_PROFILE, + SAVE_AUTOLOAD_PROFILE, + UPDATE_PRICE, + UPLOAD_BY_URL, +) +from avito.core import ( + ApiTimeouts, + AsyncPaginatedList, + AsyncPaginator, + JsonPage, + RetryOverride, + ValidationError, +) +from avito.core.deprecation import deprecated_method +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation +from avito.core.validation import ( + DateInput, + serialize_iso_date, + validate_non_empty_string, + validate_string_items, +) +from avito.promotion.models import PromotionActionResult, PromotionStatus + + +def _preview_result( + *, + action: str, + target: dict[str, object], + request_payload: dict[str, object], +) -> PromotionActionResult: + """Build result.""" + return PromotionActionResult( + action=action, + target=target, + status=PromotionStatus.PREVIEW, + applied=False, + request_payload=request_payload, + details={"validated": True}, + ) + + +StatsDate = DateInput + + +def _serialize_stats_date(value: StatsDate) -> str: + """Serialize stats date.""" + return serialize_iso_date("date", value) + + +def _bounded_total(total: int | None, max_items: int | None) -> int | None: + """Return bounded total.""" + if max_items is None: + return total + if total is None: + 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: + """Return whether next ads page.""" + if page_item_count == 0 or page_size <= 0: + return False + if max_items is not None and already_collected + collected_count >= max_items: + return False + if total is not None: + return already_collected + collected_count < min(total, max_items or total) + return page_item_count >= page_size + + +@dataclass(slots=True, frozen=True) +class AsyncAd(AsyncDomainObject): + """Доменный объект объявления.""" + + __swagger_domain__ = "ads" + __sdk_factory__ = "ad" + __sdk_factory_args__ = {"item_id": "path.item_id", "user_id": "path.user_id"} + + item_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/core/v1/accounts/{user_id}/items/{item_id}", + spec="Объявления.json", + operation_id="getItemInfo", + variant="async", + ) + async def get( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> Listing: + """Получает объявление по `item_id`. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `Listing` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + item_id, user_id = await self._require_ids() + return await self._execute( + GET_ITEM, + path_params={"user_id": user_id, "item_id": item_id}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/core/v1/items", + spec="Объявления.json", + operation_id="getItemsInfo", + variant="async", + ) + async def list( + self, + *, + status: ListingStatus | str | None = None, + limit: int | None = None, + page_size: int | None = None, + offset: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AsyncPaginatedList[Listing]: + """Возвращает объявления аккаунта с ленивой пагинацией. + + Аргументы: + status: фильтрует результат по статусу. + limit: ограничивает размер возвращаемой выборки. + page_size: задает размер страницы для ленивой пагинации. + offset: задает смещение первой записи в выборке. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + Ленивый `AsyncPaginatedList[Listing]`; первая страница загружается при создании, следующие страницы - при итерации. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + user_id = await self._resolve_user_id(self.user_id) + resolved_page_size = page_size or limit + start_offset = offset or 0 + first_page_number = ( + start_offset // resolved_page_size + 1 + if resolved_page_size is not None and resolved_page_size > 0 + else 1 + ) + result = await self._execute( + LIST_ITEMS, + query={ + "user_id": user_id, + "status": status, + "per_page": resolved_page_size, + "page": first_page_number, + }, + timeout=timeout, + retry=retry, + ) + list_result = result + page_size = ( + resolved_page_size + if resolved_page_size and resolved_page_size > 0 + else len(list_result.items) + ) + max_items = limit if limit is not None and limit >= 0 else None + page_offset = start_offset % page_size if page_size > 0 else 0 + available_items = list_result.items[page_offset:] + first_items = available_items[:max_items] if max_items is not None else available_items + first_page = JsonPage( + items=list(first_items), + total=_bounded_total(list_result.total, max_items), + source_total=list_result.total, + page=first_page_number, + per_page=page_size if page_size > 0 else None, + has_next_page=_has_next_ads_page( + page_item_count=len(list_result.items), + collected_count=len(first_items), + page_size=page_size, + total=list_result.total, + max_items=max_items, + already_collected=0, + ), + ) + return AsyncPaginator( + lambda page, cursor: self._fetch_ads_page( + page=page, + user_id=user_id, + status=status, + page_size=page_size, + max_items=max_items, + first_page_number=first_page_number, + ) + ).as_list(start_page=first_page_number, first_page=first_page) + + @swagger_operation( + "POST", + "/core/v1/items/{item_id}/update_price", + spec="Объявления.json", + operation_id="updatePrice", + variant="async", + method_args={"price": "body.price"}, + ) + async def update_price( + self, + *, + price: int | float, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> UpdatePriceResult: + """Обновляет цену текущего объявления. + + Аргументы: + price: новое значение цены. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `UpdatePriceResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + item_id = self._require_item_id() + return await self._execute( + UPDATE_PRICE, + path_params={"item_id": item_id}, + request=UpdatePriceRequest(price=price), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + async def _fetch_ads_page( + self, + *, + page: int | None, + user_id: int | None, + status: ListingStatus | str | None, + page_size: int, + max_items: int | None, + first_page_number: int, + ) -> JsonPage[Listing]: + """Fetch ads page.""" + if page is None: + raise ValidationError("Для операции требуется `page`.") + + already_collected = max(page - first_page_number, 0) * page_size + remaining = max_items - already_collected if max_items is not None else None + if remaining is not None and remaining <= 0: + return JsonPage(items=[], total=max_items, page=page, per_page=page_size) + result = await self._execute( + LIST_ITEMS, + query={ + "user_id": user_id, + "status": status, + "per_page": min(page_size, remaining) if remaining is not None else page_size, + "page": page, + }, + ) + list_result = result + items = list_result.items[:remaining] if remaining is not None else list_result.items + return JsonPage( + items=list(items), + total=_bounded_total(list_result.total, max_items), + source_total=list_result.total, + page=page, + per_page=page_size, + has_next_page=_has_next_ads_page( + page_item_count=len(list_result.items), + collected_count=len(items), + page_size=page_size, + total=list_result.total, + max_items=max_items, + already_collected=already_collected, + ), + ) + + def _require_item_id(self) -> int: + """Validate required item id.""" + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return int(self.item_id) + + async def _require_ids(self) -> tuple[int, int]: + """Validate required ids.""" + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return int(self.item_id), await self._resolve_user_id(self.user_id) + + +@dataclass(slots=True, frozen=True) +class AsyncAdStats(AsyncDomainObject): + """Доменный объект статистики объявлений.""" + + __swagger_domain__ = "ads" + __sdk_factory__ = "ad_stats" + __sdk_factory_args__ = {"item_id": "path.item_id", "user_id": "path.user_id"} + + item_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/core/v1/accounts/{user_id}/calls/stats", + spec="Объявления.json", + operation_id="postCallsStats", + variant="async", + method_args={ + "date_from": "body.dateFrom", + "date_to": "body.dateTo", + }, + ) + async def get_calls_stats( + self, + *, + date_from: StatsDate, + date_to: StatsDate, + item_ids: list[int] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CallsStatsResult: + """Получает статистику звонков. + + Аргументы: + date_from: начальная дата или дата-время периода. + date_to: конечная дата или дата-время периода. + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CallsStatsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + user_id = await self._require_user_id() + resolved_item_ids = item_ids or ([int(self.item_id)] if self.item_id is not None else []) + return await self._execute( + GET_CALLS_STATS, + path_params={"user_id": user_id}, + request=CallsStatsRequest( + item_ids=resolved_item_ids, + date_from=_serialize_stats_date(date_from), + date_to=_serialize_stats_date(date_to), + ), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/stats/v1/accounts/{user_id}/items", + spec="Объявления.json", + operation_id="itemStatsShallow", + variant="async", + method_args={ + "date_from": "body.dateFrom", + "date_to": "body.dateTo", + }, + ) + async def get_item_stats( + self, + *, + date_from: StatsDate, + date_to: StatsDate, + item_ids: list[int] | None = None, + fields: list[str] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ItemStatsResult: + """Получает статистику по списку объявлений. + + Аргументы: + date_from: начальная дата или дата-время периода. + date_to: конечная дата или дата-время периода. + item_ids: список идентификаторов объявлений. + fields: список запрошенных полей. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ItemStatsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + user_id = await self._require_user_id() + resolved_item_ids = item_ids or ([int(self.item_id)] if self.item_id is not None else []) + return await self._execute( + GET_ITEM_STATS, + path_params={"user_id": user_id}, + request=ItemStatsRequest( + item_ids=resolved_item_ids, + date_from=_serialize_stats_date(date_from), + date_to=_serialize_stats_date(date_to), + fields=fields or [], + ), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/stats/v2/accounts/{user_id}/items", + spec="Объявления.json", + operation_id="itemAnalytics", + variant="async", + method_args={ + "date_from": "body.dateFrom", + "date_to": "body.dateTo", + "metrics": "body.metrics", + "grouping": "body.grouping", + "limit": "body.limit", + "offset": "body.offset", + }, + ) + async def get_item_analytics( + self, + *, + date_from: StatsDate, + date_to: StatsDate, + metrics: list[str], + grouping: AdAnalyticsGroupingInput, + limit: int, + offset: int, + item_ids: list[int] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ItemAnalyticsResult: + """Получает аналитику по профилю. + + Аргументы: + date_from: начальная дата или дата-время периода. + date_to: конечная дата или дата-время периода. + metrics: список метрик статистики, которые нужно вернуть. + grouping: группировка статистики или расходов. + limit: максимальное количество элементов в ответе. + offset: смещение выборки. + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ItemAnalyticsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + user_id = await self._require_user_id() + return await self._execute( + GET_ITEM_ANALYTICS, + path_params={"user_id": user_id}, + request=ItemAnalyticsRequest( + date_from=_serialize_stats_date(date_from), + date_to=_serialize_stats_date(date_to), + metrics=metrics, + grouping=grouping, + limit=limit, + offset=offset, + ), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/stats/v2/accounts/{user_id}/spendings", + spec="Объявления.json", + operation_id="accountSpendings", + variant="async", + method_args={ + "date_from": "body.dateFrom", + "date_to": "body.dateTo", + "spending_types": "body.spendingTypes", + "grouping": "body.grouping", + }, + ) + async def get_account_spendings( + self, + *, + date_from: StatsDate, + date_to: StatsDate, + spending_types: list[str], + grouping: AdSpendingsGroupingInput, + item_ids: list[int] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AccountSpendings: + """Получает статистику расходов профиля. + + Аргументы: + date_from: начальная дата или дата-время периода. + date_to: конечная дата или дата-время периода. + spending_types: типы расходов, включаемые в отчет. + grouping: группировка статистики или расходов. + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AccountSpendings` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + user_id = await self._require_user_id() + resolved_item_ids = item_ids or ([int(self.item_id)] if self.item_id is not None else []) + return await self._execute( + GET_ACCOUNT_SPENDINGS, + path_params={"user_id": user_id}, + request=SpendingsRequest( + date_from=_serialize_stats_date(date_from), + date_to=_serialize_stats_date(date_to), + spending_types=spending_types, + grouping=grouping, + item_ids=resolved_item_ids, + ), + timeout=timeout, + retry=retry, + ) + + async def _require_user_id(self) -> int: + """Validate required user id.""" + return await self._resolve_user_id(self.user_id) + + +@dataclass(slots=True, frozen=True) +class AsyncAdPromotion(AsyncDomainObject): + """Доменный объект продвижения объявления.""" + + __swagger_domain__ = "ads" + __sdk_factory__ = "ad_promotion" + __sdk_factory_args__ = {"item_id": "path.item_id", "user_id": "path.user_id"} + + item_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/core/v1/accounts/{user_id}/vas/prices", + spec="Объявления.json", + operation_id="vasPrices", + variant="async", + method_args={"item_ids": "body.item_ids"}, + ) + async def get_vas_prices( + self, + *, + item_ids: list[int], + location_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> VasPricesResult: + """Получает цены продвижения и доступные услуги. + + Аргументы: + item_ids: список идентификаторов объявлений. + location_id: идентификатор локации для расчета доступности или цены услуги. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `VasPricesResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + user_id = await self._require_user_id() + return await self._execute( + GET_VAS_PRICES, + path_params={"user_id": user_id}, + request=VasPricesRequest(item_ids=item_ids, location_id=location_id), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "PUT", + "/core/v1/accounts/{user_id}/items/{item_id}/vas", + spec="Объявления.json", + operation_id="putItemVas", + variant="async", + method_args={"vas_id": "body.vas_id"}, + ) + async def apply_vas( + self, + *, + vas_id: str, + dry_run: bool = False, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionActionResult: + """Применяет дополнительные услуги к объявлению. + + Аргументы: + vas_id: идентификатор VAS-услуги. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + item_id, user_id = await self._require_ids() + validate_non_empty_string("vas_id", vas_id) + request_payload = ApplyVasRequest(vas_id=vas_id).to_payload() + target: dict[str, object] = {"item_id": item_id, "user_id": user_id} + if dry_run: + return _preview_result( + action="apply_vas", + target=target, + request_payload=request_payload, + ) + payload = await self._execute( + APPLY_ITEM_VAS, + path_params={"user_id": user_id, "item_id": item_id}, + request=ApplyVasRequest(vas_id=vas_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="apply_vas", + target=target, + request_payload=request_payload, + ) + + @swagger_operation( + "PUT", + "/core/v2/accounts/{user_id}/items/{item_id}/vas_packages", + spec="Объявления.json", + operation_id="putItemVasPackageV2", + variant="async", + method_args={"package_code": "body.package_id"}, + ) + async def apply_vas_package( + self, + *, + package_code: str, + dry_run: bool = False, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionActionResult: + """Применяет пакет дополнительных услуг. + + Аргументы: + package_code: код пакета продвижения. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + item_id, user_id = await self._require_ids() + validate_non_empty_string("package_code", package_code) + request_payload = ApplyVasPackageRequest(package_code=package_code).to_payload() + target: dict[str, object] = {"item_id": item_id, "user_id": user_id} + if dry_run: + return _preview_result( + action="apply_vas_package", + target=target, + request_payload=request_payload, + ) + payload = await self._execute( + APPLY_ITEM_VAS_PACKAGE, + path_params={"user_id": user_id, "item_id": item_id}, + request=ApplyVasPackageRequest(package_code=package_code), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="apply_vas_package", + target=target, + request_payload=request_payload, + ) + + @swagger_operation( + "PUT", + "/core/v2/items/{item_id}/vas", + spec="Объявления.json", + operation_id="applyVas", + variant="async", + method_args={"slugs": "body.slugs"}, + ) + async def apply_vas_direct( + self, + *, + slugs: list[str], + dry_run: bool = False, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionActionResult: + """Применяет услуги продвижения через прямой v2 endpoint. + + Аргументы: + slugs: slug-идентификаторы узлов дерева категорий. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + item_id = self._require_item_id() + validate_string_items("slugs", slugs) + request_payload = ApplyVasDirectRequest(slugs=slugs).to_payload() + target: dict[str, object] = {"item_id": item_id} + if dry_run: + return _preview_result( + action="apply_vas_direct", + target=target, + request_payload=request_payload, + ) + payload = await self._execute( + APPLY_VAS_DIRECT, + path_params={"item_id": item_id}, + request=ApplyVasDirectRequest(slugs=slugs), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="apply_vas_direct", + target=target, + request_payload=request_payload, + ) + + def _require_item_id(self) -> int: + """Validate required item id.""" + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return int(self.item_id) + + async def _require_user_id(self) -> int: + """Validate required user id.""" + return await self._resolve_user_id(self.user_id) + + async def _require_ids(self) -> tuple[int, int]: + """Validate required ids.""" + return self._require_item_id(), await self._require_user_id() + + +@dataclass(slots=True, frozen=True) +class AsyncAutoloadProfile(AsyncDomainObject): + """Доменный объект профиля автозагрузки.""" + + __swagger_domain__ = "ads" + __sdk_factory__ = "autoload_profile" + __sdk_factory_args__ = {"user_id": "path.user_id"} + + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/autoload/v2/profile", + spec="Автозагрузка.json", + operation_id="getProfileV2", + variant="async", + ) + async def get( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadProfileSettings: + """Получает профиль автозагрузки. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadProfileSettings` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_AUTOLOAD_PROFILE, timeout=timeout, retry=retry) + + @swagger_operation( + "POST", + "/autoload/v2/profile", + spec="Автозагрузка.json", + operation_id="createOrUpdateProfileV2", + variant="async", + method_args={ + "is_enabled": "body.autoload_enabled", + "feed_url": "body.feeds_data", + "report_email": "body.report_email", + "schedule_rate": "body.schedule[].rate", + }, + ) + async def save( + self, + *, + is_enabled: bool, + feed_url: str, + report_email: str, + schedule_rate: int, + schedule_weekdays: list[int] | None = None, + schedule_time_slots: list[int] | None = None, + feed_name: str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AdsActionResult: + """Сохраняет профиль автозагрузки. + + Аргументы: + is_enabled: включает или отключает профиль автозагрузки. + feed_url: URL фида автозагрузки. + report_email: email для отправки отчетов автозагрузки. + schedule_rate: ставка расписания продвижения. + schedule_weekdays: дни недели для расписания; если не передано, используется полный недельный набор. + schedule_time_slots: временные интервалы расписания; если не передано, используется первый слот. + feed_name: имя фида автозагрузки. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AdsActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SAVE_AUTOLOAD_PROFILE, + request=AutoloadProfileUpdateRequest( + is_enabled=is_enabled, + report_email=report_email, + schedule_rate=schedule_rate, + schedule_weekdays=schedule_weekdays or [0, 1, 2, 3, 4, 5, 6], + schedule_time_slots=schedule_time_slots or [0], + feed_name=feed_name, + feed_url=feed_url, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoload/v1/upload", + spec="Автозагрузка.json", + operation_id="upload", + variant="async", + method_args={"url": "constant.url"}, + ) + async def upload_by_url( + self, + *, + url: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> UploadResult: + """Загружает файл по ссылке. + + Аргументы: + url: URL источника данных. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `UploadResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPLOAD_BY_URL, + request=UploadByUrlRequest(url=url), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoload/v1/user-docs/tree", + spec="Автозагрузка.json", + operation_id="userDocsTree", + variant="async", + ) + async def get_tree( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadTreeResult: + """Получает дерево категорий. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadTreeResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_AUTOLOAD_TREE, timeout=timeout, retry=retry) + + @swagger_operation( + "GET", + "/autoload/v1/user-docs/node/{node_slug}/fields", + spec="Автозагрузка.json", + operation_id="userDocsNodeFields", + variant="async", + method_args={"node_slug": "path.node_slug"}, + ) + async def get_node_fields( + self, + *, + node_slug: str, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutoloadFieldsResult: + """Получает поля категории. + + Аргументы: + node_slug: slug узла дерева категорий. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadFieldsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_AUTOLOAD_NODE_FIELDS, + path_params={"node_slug": node_slug}, + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncAutoloadReport(AsyncDomainObject): + """Доменный объект отчета автозагрузки.""" + + __swagger_domain__ = "ads" + __sdk_factory__ = "autoload_report" + __sdk_factory_args__ = {"report_id": "path.report_id"} + + report_id: int | str | None = None + + @swagger_operation( + "GET", + "/autoload/v3/reports/{report_id}", + spec="Автозагрузка.json", + operation_id="getReportByIdV3", + variant="async", + ) + async def get( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadReportDetails: + """Получает конкретный отчет v3. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadReportDetails` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + report_id = self._require_report_id() + return await self._execute( + GET_AUTOLOAD_REPORT, + path_params={"report_id": report_id}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoload/v2/reports", + spec="Автозагрузка.json", + operation_id="getReportsV2", + variant="async", + ) + async def list( + self, + *, + limit: int | None = None, + offset: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AsyncPaginatedList[AutoloadReportSummary]: + """Возвращает отчеты Автозагрузки с ленивой пагинацией. + + Аргументы: + limit: ограничивает размер возвращаемой выборки. + offset: задает смещение первой записи в выборке. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + Ленивый `AsyncPaginatedList[AutoloadReportSummary]`; первая страница загружается при создании, следующие страницы - при итерации. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + page_size = limit or 25 + base_offset = offset or 0 + + async def fetch_page( + page: int | None, _cursor: str | None + ) -> JsonPage[AutoloadReportSummary]: + """Fetch one page of results.""" + current_page = page or 1 + current_offset = base_offset + (current_page - 1) * page_size + result = await self._execute( + LIST_AUTOLOAD_REPORTS, + query={"limit": page_size, "offset": current_offset}, + timeout=timeout, + retry=retry, + ) + reports = result + return JsonPage( + items=reports.items, + total=reports.total, + page=current_page, + per_page=page_size, + ) + + return AsyncPaginator(fetch_page).as_list(first_page=await fetch_page(1, None)) + + @swagger_operation( + "GET", + "/autoload/v3/reports/last_completed_report", + spec="Автозагрузка.json", + operation_id="getLastCompletedReportV3", + variant="async", + ) + async def get_last_completed( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadReportDetails: + """Получает последний завершенный отчет. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadReportDetails` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_AUTOLOAD_LAST_COMPLETED_REPORT, timeout=timeout, retry=retry) + + @swagger_operation( + "GET", + "/autoload/v2/reports/{report_id}/items", + spec="Автозагрузка.json", + operation_id="getReportItemsById", + variant="async", + ) + async def get_items( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadReportItemsResult: + """Возвращает позиции выбранного отчета Автозагрузки. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadReportItemsResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + report_id = self._require_report_id() + return await self._execute( + GET_AUTOLOAD_REPORT_ITEMS, + path_params={"report_id": report_id}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoload/v2/reports/{report_id}/items/fees", + spec="Автозагрузка.json", + operation_id="getReportItemsFeesById", + variant="async", + ) + async def get_fees( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadFeesResult: + """Получает списания по объявлениям отчета. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadFeesResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + report_id = self._require_report_id() + return await self._execute( + GET_AUTOLOAD_REPORT_FEES, + path_params={"report_id": report_id}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoload/v2/items/ad_ids", + spec="Автозагрузка.json", + operation_id="getAdIdsByAvitoIds", + variant="async", + method_args={"avito_ids": "query.query"}, + ) + async def get_ad_ids_by_avito_ids( + self, + *, + avito_ids: Sequence[int], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> IdMappingResult: + """Получает ad ids по avito ids. + + Аргументы: + avito_ids: список идентификаторов объявлений на Avito. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `IdMappingResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_AD_IDS_BY_AVITO_IDS, + query={"query": ",".join(str(item) for item in avito_ids)}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoload/v2/items/avito_ids", + spec="Автозагрузка.json", + operation_id="getAvitoIdsByAdIds", + variant="async", + method_args={"ad_ids": "query.query"}, + ) + async def get_avito_ids_by_ad_ids( + self, + *, + ad_ids: Sequence[int], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> IdMappingResult: + """Получает avito ids по ad ids. + + Аргументы: + ad_ids: список внешних идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `IdMappingResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_AVITO_IDS_BY_AD_IDS, + query={"query": ",".join(str(item) for item in ad_ids)}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoload/v2/reports/items", + spec="Автозагрузка.json", + operation_id="getAutoloadItemsInfoV2", + variant="async", + method_args={"item_ids": "query.query"}, + ) + async def get_items_info( + self, + *, + item_ids: Sequence[int], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutoloadReportItemsResult: + """Получает информацию по объявлениям автозагрузки. + + Аргументы: + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadReportItemsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_AUTOLOAD_ITEMS_INFO, + query={"query": ",".join(str(item) for item in item_ids)}, + timeout=timeout, + retry=retry, + ) + + def _require_report_id(self) -> int: + """Validate required report id.""" + if self.report_id is None: + raise ValidationError("Для операции требуется `report_id`.") + return int(self.report_id) + + +@dataclass(slots=True, frozen=True) +class AsyncAutoloadArchive(AsyncDomainObject): + """Доменный объект архивных операций автозагрузки.""" + + __swagger_domain__ = "ads" + __sdk_factory__ = "autoload_archive" + __sdk_factory_args__ = {"report_id": "path.report_id"} + + report_id: int | str | None = None + + @swagger_operation( + "GET", + "/autoload/v1/profile", + spec="Автозагрузка.json", + operation_id="getProfile", + variant="async", + deprecated=True, + legacy=True, + ) + @deprecated_method( + symbol="AsyncAutoloadArchive.get_profile", + replacement="autoload_profile().get", + removal_version="1.3.0", + deprecated_since="1.1.0", + ) + async def get_profile( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutoloadProfileSettings: + """Получает архивный профиль автозагрузки. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutoloadProfileSettings` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_ARCHIVE_PROFILE, timeout=timeout, retry=retry) + + @swagger_operation( + "POST", + "/autoload/v1/profile", + spec="Автозагрузка.json", + operation_id="createOrUpdateProfile", + variant="async", + deprecated=True, + legacy=True, + method_args={ + "is_enabled": "body.autoload_enabled", + "upload_url": "body.upload_url", + "report_email": "body.report_email", + "schedule_rate": "body.schedule[].rate", + }, + ) + @deprecated_method( + symbol="AsyncAutoloadArchive.save_profile", + replacement="autoload_profile().save", + removal_version="1.3.0", + deprecated_since="1.1.0", + ) + async def save_profile( + self, + *, + is_enabled: bool, + upload_url: str, + report_email: str, + schedule_rate: int, + schedule_weekdays: list[int] | None = None, + schedule_time_slots: list[int] | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AdsActionResult: + """Сохраняет архивный профиль автозагрузки. + + Аргументы: + is_enabled: включает или отключает профиль автозагрузки. + upload_url: URL фида автозагрузки. + report_email: email для отправки отчетов автозагрузки. + schedule_rate: ставка расписания продвижения. + schedule_weekdays: дни недели для расписания; если не передано, используется полный недельный набор. + schedule_time_slots: временные интервалы расписания; если не передано, используется первый слот. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AdsActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SAVE_ARCHIVE_PROFILE, + request=AutoloadProfileUpdateRequest( + is_enabled=is_enabled, + report_email=report_email, + schedule_rate=schedule_rate, + schedule_weekdays=schedule_weekdays or [0, 1, 2, 3, 4, 5, 6], + schedule_time_slots=schedule_time_slots or [0], + upload_url=upload_url, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoload/v2/reports/last_completed_report", + spec="Автозагрузка.json", + operation_id="getLastCompletedReport", + variant="async", + deprecated=True, + legacy=True, + ) + @deprecated_method( + symbol="AsyncAutoloadArchive.get_last_completed_report", + replacement="autoload_report().get_last_completed", + removal_version="1.3.0", + deprecated_since="1.1.0", + ) + async def get_last_completed_report( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> LegacyAutoloadReport: + """Получает архивную статистику по последней выгрузке. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `LegacyAutoloadReport` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_ARCHIVE_LAST_COMPLETED_REPORT, timeout=timeout, retry=retry) + + @swagger_operation( + "GET", + "/autoload/v2/reports/{report_id}", + spec="Автозагрузка.json", + operation_id="getReportByIdV2", + variant="async", + deprecated=True, + legacy=True, + ) + @deprecated_method( + symbol="AsyncAutoloadArchive.get_report", + replacement="autoload_report().get", + removal_version="1.3.0", + deprecated_since="1.1.0", + ) + async def get_report( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> LegacyAutoloadReport: + """Получает архивную статистику по конкретной выгрузке. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `LegacyAutoloadReport` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + report_id = self._require_report_id() + return await self._execute( + GET_ARCHIVE_REPORT, + path_params={"report_id": report_id}, + timeout=timeout, + retry=retry, + ) + + def _require_report_id(self) -> int: + """Validate required report id.""" + if self.report_id is None: + raise ValidationError("Для операции требуется `report_id`.") + return int(self.report_id) + + +__all__ = ( + "AsyncAd", + "AsyncAdPromotion", + "AsyncAdStats", + "AsyncAutoloadArchive", + "AsyncAutoloadProfile", + "AsyncAutoloadReport", +) diff --git a/avito/async_client.py b/avito/async_client.py new file mode 100644 index 0000000..b01caa9 --- /dev/null +++ b/avito/async_client.py @@ -0,0 +1,952 @@ +"""Асинхронный высокоуровневый клиент SDK Avito.""" + +from __future__ import annotations + +import asyncio +from datetime import date, datetime +from pathlib import Path + +import httpx + +from avito.accounts import AsyncAccount, AsyncAccountHierarchy +from avito.ads import ( + AsyncAd, + AsyncAdPromotion, + AsyncAdStats, + AsyncAutoloadArchive, + AsyncAutoloadProfile, + AsyncAutoloadReport, +) +from avito.ads.models import CallStats, ListingStats, ListingStatus, SpendingRecord +from avito.auth.async_provider import AsyncAuthProvider +from avito.auth.async_token_client import AsyncAlternateTokenClient, AsyncTokenClient +from avito.auth.settings import AuthSettings +from avito.autoteka import ( + AsyncAutotekaMonitoring, + AsyncAutotekaReport, + AsyncAutotekaScoring, + AsyncAutotekaValuation, + AsyncAutotekaVehicle, +) +from avito.client import ( + _default_summary_date_range, + _safe_summary_async, + _sum_optional_float, + _sum_optional_int, + _summary_unavailable_section, +) +from avito.config import AvitoSettings +from avito.core.async_transport import AsyncTransport +from avito.core.exceptions import AvitoError, ClientClosedError +from avito.core.types import TransportDebugInfo +from avito.cpa import ( + AsyncCallTrackingCall, + AsyncCpaArchive, + AsyncCpaCall, + AsyncCpaChat, + AsyncCpaLead, +) +from avito.jobs import ( + AsyncApplication, + AsyncJobDictionary, + AsyncJobWebhook, + AsyncResume, + AsyncVacancy, +) +from avito.messenger import ( + AsyncChat, + AsyncChatMedia, + AsyncChatMessage, + AsyncChatWebhook, + AsyncSpecialOfferCampaign, +) +from avito.orders import ( + AsyncDeliveryOrder, + AsyncDeliveryTask, + AsyncOrder, + AsyncOrderLabel, + AsyncSandboxDelivery, + AsyncStock, +) +from avito.orders.models import OrderStatus +from avito.promotion import ( + AsyncAutostrategyCampaign, + AsyncBbipPromotion, + AsyncCpaAuction, + AsyncPromotionOrder, + AsyncTargetActionPricing, + AsyncTrxPromotion, +) +from avito.promotion.models import PromotionOrderServiceStatus, PromotionOrderStatus +from avito.ratings import AsyncRatingProfile, AsyncReview, AsyncReviewAnswer +from avito.realty import ( + AsyncRealtyAnalyticsReport, + AsyncRealtyBooking, + AsyncRealtyListing, + AsyncRealtyPricing, +) +from avito.summary import ( + AccountHealthSummary, + CapabilityDiscoveryResult, + CapabilityInfo, + ChatSummary, + ListingHealthItem, + ListingHealthSummary, + OrderSummary, + PromotionSummary, + ReviewSummary, + SummaryUnavailableSection, +) +from avito.tariffs import AsyncTariff + +SummaryDate = date | datetime | str + + +class AsyncAvitoClient: + """Асинхронная публичная точка входа SDK с factory-методами портированных доменов.""" + + def __init__( + self, + settings: AvitoSettings | None = None, + *, + client_id: str | None = None, + client_secret: str | None = None, + http_client: httpx.AsyncClient | None = None, + ) -> None: + """Initialize AsyncAvitoClient.""" + if client_id is not None or client_secret is not None: + auth = AuthSettings(client_id=client_id, client_secret=client_secret) + settings = AvitoSettings(auth=auth) + self._closed = False + self._entered = False + self._settings = (settings or AvitoSettings.from_env()).validate_required() + self._external_http_client = http_client + self._auth_provider: AsyncAuthProvider | None = None + self._transport: AsyncTransport | None = None + + @classmethod + def from_env(cls, *, env_file: str | Path | None = ".env") -> AsyncAvitoClient: + """Создает async-клиент из переменных окружения и optional `.env` файла.""" + + return cls(AvitoSettings.from_env(env_file=env_file)) + + @classmethod + def _from_transport( + cls, + settings: AvitoSettings, + *, + transport: AsyncTransport, + auth_provider: AsyncAuthProvider, + ) -> AsyncAvitoClient: + """Run the from transport helper.""" + client = cls.__new__(cls) + client._closed = False + client._entered = True + client._settings = settings + client._external_http_client = None + client._auth_provider = auth_provider + client._transport = transport + return client + + async def __aenter__(self) -> AsyncAvitoClient: + """Enter the async context manager.""" + self._ensure_open() + if self._entered: + return self + try: + self._auth_provider = self._build_auth_provider() + self._transport = AsyncTransport( + self.settings, + auth_provider=self._auth_provider, + client=self._external_http_client, + ) + self._entered = True + return self + except BaseException: + await self.aclose() + raise + + async def __aexit__(self, *exc: object) -> None: + """Exit the async context manager.""" + await self.aclose() + + @property + def settings(self) -> AvitoSettings: + """Возвращает read-only настройки клиента.""" + + return self._settings + + @property + def auth_provider(self) -> AsyncAuthProvider: + """Возвращает read-only auth provider клиента.""" + + self._ensure_ready() + if self._auth_provider is None: + raise RuntimeError("AsyncAvitoClient не инициализирован: используйте 'async with'.") + return self._auth_provider + + @property + def transport(self) -> AsyncTransport: + """Возвращает read-only async transport клиента.""" + + return self._require_transport() + + def auth(self) -> AsyncAuthProvider: + """Возвращает объект аутентификации и async token-flow операций.""" + + self._ensure_open() + return self.auth_provider + + def debug_info(self) -> TransportDebugInfo: + """Возвращает безопасный снимок transport-настроек для диагностики.""" + + return self._require_transport().debug_info() + + async def business_summary( + self, + *, + user_id: int | str | None = None, + listing_limit: int = 50, + listing_page_size: int = 50, + date_from: SummaryDate | None = None, + date_to: SummaryDate | None = None, + ) -> AccountHealthSummary: + """Возвращает итоговую async read-only сводку бизнеса.""" + + return await self.account_health( + user_id=user_id, + listing_limit=listing_limit, + listing_page_size=listing_page_size, + date_from=date_from, + date_to=date_to, + ) + + async def account_health( + self, + *, + user_id: int | str | None = None, + listing_limit: int = 50, + listing_page_size: int = 50, + date_from: SummaryDate | None = None, + date_to: SummaryDate | None = None, + ) -> AccountHealthSummary: + """Возвращает итоговую async read-only health-сводку аккаунта.""" + + resolved_user_id = await self._resolve_user_id(user_id) + async with asyncio.TaskGroup() as task_group: + balance_task = task_group.create_task(self.account(resolved_user_id).get_balance()) + listings_task = task_group.create_task( + self.listing_health( + user_id=resolved_user_id, + limit=listing_limit, + page_size=listing_page_size, + date_from=date_from, + date_to=date_to, + ) + ) + chats_task = task_group.create_task( + _safe_summary_async( + "chats", + lambda: self.chat_summary(user_id=resolved_user_id), + ) + ) + orders_task = task_group.create_task( + _safe_summary_async("orders", self.order_summary) + ) + reviews_task = task_group.create_task( + _safe_summary_async("reviews", self.review_summary) + ) + balance = balance_task.result() + listings = listings_task.result() + item_ids = [item.item_id for item in listings.items if item.item_id is not None] + promotion, promotion_unavailable = await _safe_summary_async( + "promotion", + lambda: self.promotion_summary(item_ids=item_ids), + ) + chats, chats_unavailable = chats_task.result() + orders, orders_unavailable = orders_task.result() + reviews, reviews_unavailable = reviews_task.result() + unavailable_sections = [ + *listings.unavailable_sections, + *chats_unavailable, + *orders_unavailable, + *reviews_unavailable, + *promotion_unavailable, + ] + if chats is not None: + unavailable_sections.extend(chats.unavailable_sections) + if orders is not None: + unavailable_sections.extend(orders.unavailable_sections) + if reviews is not None: + unavailable_sections.extend(reviews.unavailable_sections) + if promotion is not None: + unavailable_sections.extend(promotion.unavailable_sections) + return AccountHealthSummary( + user_id=resolved_user_id, + balance_total=balance.total, + balance_real=balance.real, + balance_bonus=balance.bonus, + listings=listings, + chats=chats, + orders=orders, + reviews=reviews, + promotion=promotion, + unavailable_sections=unavailable_sections, + ) + + async def listing_health( + self, + *, + user_id: int | str | None = None, + limit: int = 50, + page_size: int = 50, + date_from: SummaryDate | None = None, + date_to: SummaryDate | None = None, + ) -> ListingHealthSummary: + """Возвращает async health-сводку объявлений.""" + + resolved_user_id = await self._resolve_user_id(user_id) + listing_collection = await self.ad(user_id=resolved_user_id).list( + limit=limit, + page_size=page_size, + ) + listings = await listing_collection.materialize() + item_ids = [item.item_id for item in listings if item.item_id is not None] + stats_by_item_id: dict[int, ListingStats] = {} + calls_by_item_id: dict[int, CallStats] = {} + spendings_by_item_id: dict[int, SpendingRecord] = {} + unavailable_sections: list[SummaryUnavailableSection] = [] + if item_ids: + stats_date_from, stats_date_to = _default_summary_date_range(date_from, date_to) + async with asyncio.TaskGroup() as task_group: + item_stats_task = task_group.create_task( + self.ad_stats(user_id=resolved_user_id).get_item_stats( + item_ids=item_ids, + date_from=stats_date_from, + date_to=stats_date_to, + ) + ) + calls_stats_task = task_group.create_task( + self.ad_stats(user_id=resolved_user_id).get_calls_stats( + item_ids=item_ids, + date_from=stats_date_from, + date_to=stats_date_to, + ) + ) + spendings_task = task_group.create_task( + _safe_summary_async( + "spendings", + lambda: self.ad_stats(user_id=resolved_user_id).get_account_spendings( + item_ids=item_ids, + date_from=stats_date_from, + date_to=stats_date_to, + spending_types=["promotion", "presence", "commission", "rest"], + grouping="day", + ), + ) + ) + item_stats = item_stats_task.result() + calls_stats = calls_stats_task.result() + spendings, spendings_unavailable = spendings_task.result() + stats_by_item_id = { + stats.item_id: stats for stats in item_stats.items if stats.item_id is not None + } + calls_by_item_id = { + stats.item_id: stats for stats in calls_stats.items if stats.item_id is not None + } + unavailable_sections.extend(spendings_unavailable) + if spendings is not None: + spendings_by_item_id = { + item.item_id: item for item in spendings.items if item.item_id is not None + } + health_items = [ + ListingHealthItem( + item_id=listing.item_id, + title=listing.title, + status=listing.status, + price=listing.price, + url=listing.url, + is_visible=listing.is_visible, + views=stats_by_item_id[listing.item_id].views + if listing.item_id in stats_by_item_id + else None, + contacts=stats_by_item_id[listing.item_id].contacts + if listing.item_id in stats_by_item_id + else None, + favorites=stats_by_item_id[listing.item_id].favorites + if listing.item_id in stats_by_item_id + else None, + calls=calls_by_item_id[listing.item_id].calls + if listing.item_id in calls_by_item_id + else None, + spendings=spendings_by_item_id[listing.item_id].amount + if listing.item_id in spendings_by_item_id + else None, + ) + for listing in listings + ] + loaded_listings = len(health_items) + total_listings = listing_collection.source_total + listing_limit = limit if limit >= 0 else None + expected_loaded = ( + min(total_listings, listing_limit) + if total_listings is not None and listing_limit is not None + else total_listings + ) + return ListingHealthSummary( + user_id=resolved_user_id, + items=health_items, + loaded_listings=loaded_listings, + total_listings=total_listings, + listing_limit=listing_limit, + is_complete=expected_loaded is not None and loaded_listings >= expected_loaded, + visible_listings=sum(1 for item in health_items if item.is_visible is True), + active_listings=sum(1 for item in health_items if item.status is ListingStatus.ACTIVE), + total_views=_sum_optional_int(item.views for item in health_items), + total_contacts=_sum_optional_int(item.contacts for item in health_items), + total_favorites=_sum_optional_int(item.favorites for item in health_items), + total_calls=_sum_optional_int(item.calls for item in health_items), + total_spendings=_sum_optional_float(item.spendings for item in health_items), + unavailable_sections=unavailable_sections, + ) + + async def chat_summary(self, *, user_id: int | str | None = None) -> ChatSummary: + """Возвращает итоговую async read-only сводку по чатам.""" + + resolved_user_id = await self._resolve_user_id(user_id) + result = await self.chat(user_id=resolved_user_id).list() + unread_counts = [item.unread_count or 0 for item in result.items] + return ChatSummary( + user_id=resolved_user_id, + total_chats=result.total if result.total is not None else len(result.items), + unread_chats=sum(1 for count in unread_counts if count > 0), + unread_messages=sum(unread_counts), + ) + + async def order_summary(self) -> OrderSummary: + """Возвращает итоговую async read-only сводку по заказам.""" + + result = await self.order().list() + return OrderSummary( + total_orders=result.total if result.total is not None else len(result.items), + active_orders=sum( + 1 + for item in result.items + if item.status is not None and item.status is not OrderStatus.UNKNOWN + ), + ) + + async def review_summary(self) -> ReviewSummary: + """Возвращает итоговую async read-only сводку по отзывам.""" + + reviews_error: AvitoError | None = None + try: + reviews = await self.review().list() + except AvitoError as error: + reviews = None + reviews_error = error + rating = await self.rating_profile().get() + scores = [item.score for item in reviews.items if item.score is not None] if reviews else [] + average_score = sum(scores) / len(scores) if scores else None + unavailable_sections = ( + [_summary_unavailable_section("reviews", reviews_error)] + if reviews_error is not None + else [] + ) + return ReviewSummary( + total_reviews=( + reviews.total + if reviews is not None and reviews.total is not None + else rating.reviews_count + if reviews is None + else len(reviews.items) + ), + average_score=average_score if reviews is not None else rating.score, + unanswered_reviews=( + sum(1 for item in reviews.items if item.can_answer is True) + if reviews is not None + else None + ), + rating_score=rating.score, + unavailable_sections=unavailable_sections, + ) + + async def promotion_summary(self, *, item_ids: list[int] | None = None) -> PromotionSummary: + """Возвращает итоговую async read-only сводку по продвижению.""" + + if item_ids: + async with asyncio.TaskGroup() as task_group: + orders_task = task_group.create_task( + self.promotion_order().list_orders(item_ids=item_ids) + ) + services_task = task_group.create_task( + self.promotion_order().list_services(item_ids=item_ids) + ) + orders = orders_task.result() + services = services_task.result() + else: + orders = await self.promotion_order().list_orders(item_ids=item_ids) + services = None + service_items = services.items if services is not None else [] + return PromotionSummary( + total_orders=len(orders.items), + active_orders=sum( + 1 + for item in orders.items + if item.status + in { + PromotionOrderStatus.INITIALIZED, + PromotionOrderStatus.WAITING, + PromotionOrderStatus.IN_PROCESS, + PromotionOrderStatus.PROCESSED, + PromotionOrderStatus.APPLIED, + PromotionOrderStatus.AUTO, + PromotionOrderStatus.CREATED, + PromotionOrderStatus.MANUAL, + PromotionOrderStatus.PARTIAL, + } + ), + total_services=len(service_items), + available_services=sum( + 1 + for item in service_items + if item.status + in { + PromotionOrderServiceStatus.ACTIVE, + PromotionOrderServiceStatus.AVAILABLE, + } + ), + ) + + def capabilities(self) -> CapabilityDiscoveryResult: + """Возвращает справочник возможностей SDK без сетевых probe-запросов.""" + + has_user_id = self.debug_info().user_id is not None + configured_reasons = ["Настроены OAuth client_id и client_secret."] + user_id_reasons = ( + ["Настроен user_id или его можно получить через профиль."] + if has_user_id + else [ + "Для части операций SDK получит user_id через профиль или потребует явный аргумент." + ] + ) + return CapabilityDiscoveryResult( + items=[ + CapabilityInfo( + operation="account_health", + factory_method="account_health", + is_available=True, + reasons=configured_reasons + user_id_reasons, + possible_error_codes=[400, 401, 403, 429], + ), + CapabilityInfo( + operation="listing_health", + factory_method="listing_health", + is_available=True, + reasons=user_id_reasons + + [ + "400 возможен при неверном фильтре, 403 при недоступном аккаунте, 429 при лимите." + ], + possible_error_codes=[400, 403, 429], + ), + CapabilityInfo( + operation="chat_summary", + factory_method="chat_summary", + is_available=True, + reasons=user_id_reasons + + ["403 возможен без доступа к мессенджеру, 429 при лимите запросов."], + possible_error_codes=[400, 403, 429], + ), + CapabilityInfo( + operation="order_summary", + factory_method="order_summary", + is_available=True, + reasons=["Операция использует read-only список заказов."], + possible_error_codes=[400, 403, 429], + ), + CapabilityInfo( + operation="review_summary", + factory_method="review_summary", + is_available=True, + reasons=["Операция использует список отзывов и рейтинг профиля."], + possible_error_codes=[400, 403, 429], + ), + CapabilityInfo( + operation="promotion_summary", + factory_method="promotion_summary", + is_available=True, + reasons=[ + "Сводка заявок доступна без item_ids; сводка услуг требует item_ids.", + "403 возможен без доступа к продвижению, 429 при лимите запросов.", + ], + possible_error_codes=[400, 403, 429], + ), + ] + ) + + def account(self, user_id: int | str | None = None) -> AsyncAccount: + """Создает async-доменный объект аккаунта.""" + + return AsyncAccount(self._require_transport(), user_id=user_id) + + def account_hierarchy(self, user_id: int | str | None = None) -> AsyncAccountHierarchy: + """Создает async-доменный объект иерархии аккаунта.""" + + return AsyncAccountHierarchy(self._require_transport(), user_id=user_id) + + def ad(self, item_id: int | str | None = None, user_id: int | str | None = None) -> AsyncAd: + """Создает async-доменный объект объявления.""" + + return AsyncAd(self._require_transport(), item_id=item_id, user_id=user_id) + + def ad_stats( + self, item_id: int | str | None = None, user_id: int | str | None = None + ) -> AsyncAdStats: + """Создает async-доменный объект статистики объявления.""" + + return AsyncAdStats(self._require_transport(), item_id=item_id, user_id=user_id) + + def ad_promotion( + self, item_id: int | str | None = None, user_id: int | str | None = None + ) -> AsyncAdPromotion: + """Создает async-доменный объект продвижения объявления.""" + + return AsyncAdPromotion(self._require_transport(), item_id=item_id, user_id=user_id) + + def autoload_profile(self, user_id: int | str | None = None) -> AsyncAutoloadProfile: + """Создает async-доменный объект профиля автозагрузки.""" + + return AsyncAutoloadProfile(self._require_transport(), user_id=user_id) + + def autoload_report( + self, report_id: int | str | None = None + ) -> AsyncAutoloadReport: + """Создает async-доменный объект отчета автозагрузки.""" + + return AsyncAutoloadReport(self._require_transport(), report_id=report_id) + + def autoload_archive( + self, report_id: int | str | None = None + ) -> AsyncAutoloadArchive: + """Создает async-доменный объект архивных операций автозагрузки.""" + + return AsyncAutoloadArchive(self._require_transport(), report_id=report_id) + + def cpa_lead(self) -> AsyncCpaLead: + """Создает async-доменный объект CPA-лида.""" + + return AsyncCpaLead(self._require_transport()) + + def cpa_chat(self, chat_id: int | str | None = None) -> AsyncCpaChat: + """Создает async-доменный объект CPA-чата.""" + + return AsyncCpaChat(self._require_transport(), action_id=chat_id) + + def cpa_call(self) -> AsyncCpaCall: + """Создает async-доменный объект CPA-звонка.""" + + return AsyncCpaCall(self._require_transport()) + + def cpa_archive(self, call_id: int | str | None = None) -> AsyncCpaArchive: + """Создает async-доменный объект архивных операций CPA.""" + + return AsyncCpaArchive(self._require_transport(), call_id=call_id) + + def call_tracking_call(self, call_id: int | str | None = None) -> AsyncCallTrackingCall: + """Создает async-доменный объект CallTracking.""" + + return AsyncCallTrackingCall(self._require_transport(), call_id=call_id) + + def tariff(self, tariff_id: int | str | None = None) -> AsyncTariff: + """Создает async-доменный объект тарифа.""" + + return AsyncTariff(self._require_transport(), tariff_id=tariff_id) + + def review(self) -> AsyncReview: + """Создает async-доменный объект отзыва.""" + + return AsyncReview(self._require_transport()) + + def review_answer(self, answer_id: int | str | None = None) -> AsyncReviewAnswer: + """Создает async-доменный объект ответа на отзыв.""" + + return AsyncReviewAnswer(self._require_transport(), answer_id=answer_id) + + def rating_profile(self) -> AsyncRatingProfile: + """Создает async-доменный объект рейтингового профиля.""" + + return AsyncRatingProfile(self._require_transport()) + + def realty_listing( + self, + item_id: int | str | None = None, + *, + user_id: int | str | None = None, + ) -> AsyncRealtyListing: + """Создает async-доменный объект объявления недвижимости.""" + + return AsyncRealtyListing(self._require_transport(), item_id=item_id, user_id=user_id) + + def realty_booking( + self, + item_id: int | str | None = None, + *, + user_id: int | str | None = None, + ) -> AsyncRealtyBooking: + """Создает async-доменный объект бронирования недвижимости.""" + + return AsyncRealtyBooking(self._require_transport(), item_id=item_id, user_id=user_id) + + def realty_pricing( + self, + item_id: int | str | None = None, + *, + user_id: int | str | None = None, + ) -> AsyncRealtyPricing: + """Создает async-доменный объект цен недвижимости.""" + + return AsyncRealtyPricing(self._require_transport(), item_id=item_id, user_id=user_id) + + def realty_analytics_report( + self, + item_id: int | str | None = None, + *, + user_id: int | str | None = None, + ) -> AsyncRealtyAnalyticsReport: + """Создает async-доменный объект аналитического отчета недвижимости.""" + + return AsyncRealtyAnalyticsReport( + self._require_transport(), + item_id=item_id, + user_id=user_id, + ) + + def chat( + self, chat_id: int | str | None = None, *, user_id: int | str | None = None + ) -> AsyncChat: + """Создает async-доменный объект чата.""" + + return AsyncChat(self._require_transport(), chat_id=chat_id, user_id=user_id) + + def chat_message( + self, + message_id: int | str | None = None, + *, + chat_id: int | str | None = None, + user_id: int | str | None = None, + ) -> AsyncChatMessage: + """Создает async-доменный объект сообщения чата.""" + + return AsyncChatMessage( + self._require_transport(), + chat_id=chat_id, + message_id=message_id, + user_id=user_id, + ) + + def chat_webhook(self) -> AsyncChatWebhook: + """Создает async-доменный объект webhook мессенджера.""" + + return AsyncChatWebhook(self._require_transport()) + + def chat_media(self, *, user_id: int | str | None = None) -> AsyncChatMedia: + """Создает async-доменный объект медиа мессенджера.""" + + return AsyncChatMedia(self._require_transport(), user_id=user_id) + + def special_offer_campaign( + self, campaign_id: int | str | None = None + ) -> AsyncSpecialOfferCampaign: + """Создает async-доменный объект рассылки спецпредложений.""" + + return AsyncSpecialOfferCampaign(self._require_transport(), campaign_id=campaign_id) + + def vacancy(self, vacancy_id: int | str | None = None) -> AsyncVacancy: + """Создает async-доменный объект вакансии.""" + + return AsyncVacancy(self._require_transport(), vacancy_id=vacancy_id) + + def application(self) -> AsyncApplication: + """Создает async-доменный объект откликов.""" + + return AsyncApplication(self._require_transport()) + + def resume(self, resume_id: int | str | None = None) -> AsyncResume: + """Создает async-доменный объект резюме.""" + + return AsyncResume(self._require_transport(), resume_id=resume_id) + + def job_webhook(self) -> AsyncJobWebhook: + """Создает async-доменный объект webhook Авито Работы.""" + + return AsyncJobWebhook(self._require_transport()) + + def job_dictionary(self, dictionary_id: int | str | None = None) -> AsyncJobDictionary: + """Создает async-доменный объект справочника Авито Работы.""" + + return AsyncJobDictionary(self._require_transport(), dictionary_id=dictionary_id) + + def promotion_order(self, order_id: int | str | None = None) -> AsyncPromotionOrder: + """Создает async-доменный объект заявок promotion.""" + + return AsyncPromotionOrder(self._require_transport(), order_id=order_id) + + def bbip_promotion(self, item_id: int | str | None = None) -> AsyncBbipPromotion: + """Создает async-доменный объект BBIP-продвижения.""" + + return AsyncBbipPromotion(self._require_transport(), item_id=item_id) + + def trx_promotion(self, item_id: int | str | None = None) -> AsyncTrxPromotion: + """Создает async-доменный объект TrxPromo.""" + + return AsyncTrxPromotion(self._require_transport(), item_id=item_id) + + def cpa_auction(self, item_id: int | str | None = None) -> AsyncCpaAuction: + """Создает async-доменный объект CPA-аукциона.""" + + return AsyncCpaAuction(self._require_transport(), item_id=item_id) + + def target_action_pricing(self, item_id: int | str | None = None) -> AsyncTargetActionPricing: + """Создает async-доменный объект цены целевого действия.""" + + return AsyncTargetActionPricing(self._require_transport(), item_id=item_id) + + def autostrategy_campaign( + self, campaign_id: int | str | None = None + ) -> AsyncAutostrategyCampaign: + """Создает async-доменный объект кампании автостратегии.""" + + return AsyncAutostrategyCampaign(self._require_transport(), campaign_id=campaign_id) + + def order(self) -> AsyncOrder: + """Создает async-доменный объект заказа.""" + + return AsyncOrder(self._require_transport()) + + def order_label(self, task_id: int | str | None = None) -> AsyncOrderLabel: + """Создает async-доменный объект этикетки заказа.""" + + return AsyncOrderLabel(self._require_transport(), task_id=task_id) + + def delivery_order(self) -> AsyncDeliveryOrder: + """Создает async-доменный объект доставки.""" + + return AsyncDeliveryOrder(self._require_transport()) + + def sandbox_delivery(self) -> AsyncSandboxDelivery: + """Создает async-доменный объект песочницы доставки.""" + + return AsyncSandboxDelivery(self._require_transport()) + + def delivery_task(self, task_id: int | str | None = None) -> AsyncDeliveryTask: + """Создает async-доменный объект задачи доставки.""" + + return AsyncDeliveryTask(self._require_transport(), task_id=task_id) + + def stock(self) -> AsyncStock: + """Создает async-доменный объект остатков.""" + + return AsyncStock(self._require_transport()) + + def autoteka_vehicle( + self, + vehicle_id: int | str | None = None, + ) -> AsyncAutotekaVehicle: + """Создает async-доменный объект автомобиля Автотеки.""" + + return AsyncAutotekaVehicle(self._require_transport(), vehicle_id=vehicle_id) + + def autoteka_report( + self, + report_id: int | str | None = None, + ) -> AsyncAutotekaReport: + """Создает async-доменный объект отчетов Автотеки.""" + + return AsyncAutotekaReport(self._require_transport(), report_id=report_id) + + def autoteka_monitoring(self) -> AsyncAutotekaMonitoring: + """Создает async-доменный объект мониторинга Автотеки.""" + + return AsyncAutotekaMonitoring(self._require_transport()) + + def autoteka_scoring( + self, + scoring_id: int | str | None = None, + ) -> AsyncAutotekaScoring: + """Создает async-доменный объект скоринга Автотеки.""" + + return AsyncAutotekaScoring(self._require_transport(), scoring_id=scoring_id) + + def autoteka_valuation(self) -> AsyncAutotekaValuation: + """Создает async-доменный объект оценки автомобиля Автотеки.""" + + return AsyncAutotekaValuation(self._require_transport()) + + async def aclose(self) -> None: + """Закрывает transport и auth-provider; повторный вызов безопасен.""" + + transport = self._transport + auth_provider = self._auth_provider + self._closed = True + self._entered = False + self._transport = None + self._auth_provider = None + if transport is not None: + await transport.aclose() + if auth_provider is not None: + await auth_provider.aclose() + + def _build_auth_provider(self) -> AsyncAuthProvider: + """Build auth provider.""" + token_client = AsyncTokenClient( + self.settings.auth, + client=self._external_http_client, + sdk_settings=self.settings, + ) + alternate_token_client = AsyncAlternateTokenClient( + self.settings.auth, + client=self._external_http_client, + sdk_settings=self.settings, + ) + autoteka_token_client = AsyncTokenClient( + self.settings.auth, + token_url=self.settings.auth.autoteka_token_url, + client=self._external_http_client, + sdk_settings=self.settings, + ) + return AsyncAuthProvider( + self.settings.auth, + token_client=token_client, + alternate_token_client=alternate_token_client, + autoteka_token_client=autoteka_token_client, + ) + + def _ensure_open(self) -> None: + """Ensure open.""" + if self._closed: + raise ClientClosedError("Клиент закрыт; создайте новый AsyncAvitoClient.") + + def _ensure_ready(self) -> None: + """Ensure ready.""" + self._ensure_open() + if not self._entered: + raise RuntimeError( + "AsyncAvitoClient не инициализирован: используйте 'async with' " + "или дождитесь '__aenter__'." + ) + + def _require_transport(self) -> AsyncTransport: + """Validate required transport.""" + self._ensure_ready() + if self._transport is None: + raise RuntimeError("AsyncAvitoClient не инициализирован: используйте 'async with'.") + return self._transport + + async def _resolve_user_id(self, user_id: int | str | None = None) -> int: + """Resolve user id.""" + return await AsyncAccount(self._require_transport(), user_id=user_id)._resolve_user_id( + user_id + ) + + +__all__ = ("AsyncAvitoClient",) diff --git a/avito/auth/__init__.py b/avito/auth/__init__.py index df3f76d..bc4edcf 100644 --- a/avito/auth/__init__.py +++ b/avito/auth/__init__.py @@ -1,5 +1,7 @@ """Пакет аутентификации.""" +from avito.auth.async_provider import AsyncAuthProvider +from avito.auth.async_token_client import AsyncAlternateTokenClient, AsyncTokenClient from avito.auth.models import ( AccessToken, ClientCredentialsRequest, @@ -12,6 +14,9 @@ __all__ = ( "AccessToken", "AlternateTokenClient", + "AsyncAlternateTokenClient", + "AsyncAuthProvider", + "AsyncTokenClient", "AuthProvider", "AuthSettings", "ClientCredentialsRequest", diff --git a/avito/auth/_cache.py b/avito/auth/_cache.py new file mode 100644 index 0000000..27c6061 --- /dev/null +++ b/avito/auth/_cache.py @@ -0,0 +1,73 @@ +"""Shared OAuth token cache for sync and async auth providers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +from avito.auth.models import AccessToken, TokenResponse +from avito.core.exceptions import ResponseMappingError + + +@dataclass(slots=True) +class TokenCache: + """Mutable in-memory token state without IO or locking.""" + + access_token: AccessToken | None = None + refresh_token: str | None = None + autoteka_access_token: AccessToken | None = None + + def access_is_fresh(self, now: datetime) -> bool: + """Return whether the cached access token is still fresh.""" + return self.access_token is not None and not self.access_token.is_expired(now) + + def autoteka_is_fresh(self, now: datetime) -> bool: + """Return whether the cached Autoteka token is still fresh.""" + return self.autoteka_access_token is not None and not self.autoteka_access_token.is_expired( + now + ) + + def reset_access(self) -> None: + """Clear the cached access token.""" + self.access_token = None + + def reset_autoteka(self) -> None: + """Clear the cached Autoteka token.""" + self.autoteka_access_token = None + + +def map_token_response(payload: object, *, now: datetime | None = None) -> TokenResponse: + """Map raw OAuth JSON into a typed token response.""" + + if not isinstance(payload, dict): + raise ResponseMappingError("OAuth-ответ должен быть JSON-объектом.", payload=payload) + + access_token = payload.get("access_token") + if not isinstance(access_token, str) or not access_token: + raise ResponseMappingError("В OAuth-ответе отсутствует `access_token`.", payload=payload) + + raw_expires_in = payload.get("expires_in", 0) + if not isinstance(raw_expires_in, int | float) or isinstance(raw_expires_in, bool): + raise ResponseMappingError("Поле `expires_in` должно быть числом.", payload=payload) + + refresh_token = payload.get("refresh_token") + if refresh_token is not None and not isinstance(refresh_token, str): + raise ResponseMappingError("Поле `refresh_token` должно быть строкой.", payload=payload) + + token_type = payload.get("token_type", "Bearer") + if not isinstance(token_type, str): + raise ResponseMappingError("Поле `token_type` должно быть строкой.", payload=payload) + + issued_at = now or datetime.now(UTC) + return TokenResponse( + access_token=AccessToken( + value=access_token, + expires_at=issued_at + timedelta(seconds=raw_expires_in), + token_type=token_type, + ), + refresh_token=refresh_token, + scope=payload.get("scope") if isinstance(payload.get("scope"), str) else None, + ) + + +__all__ = ("TokenCache", "map_token_response") diff --git a/avito/auth/async_provider.py b/avito/auth/async_provider.py new file mode 100644 index 0000000..b3b56c6 --- /dev/null +++ b/avito/auth/async_provider.py @@ -0,0 +1,193 @@ +"""Async authentication provider for the SDK.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Protocol + +from avito.auth._cache import TokenCache +from avito.auth.async_token_client import AsyncAlternateTokenClient, AsyncTokenClient +from avito.auth.models import ( + AccessToken, + ClientCredentialsRequest, + RefreshTokenRequest, + TokenResponse, +) +from avito.auth.settings import AuthSettings +from avito.core.exceptions import AuthenticationError, ConfigurationError + + +class AsyncTokenFetcher(Protocol): + """Контракт async-получения нового access token из внешнего источника.""" + + async def __call__(self, settings: AuthSettings) -> TokenResponse: + """Fetch a token payload.""" + ... + + +@dataclass(slots=True) +class AsyncAuthProvider: + """Поставляет и кэширует токен доступа для async transport-слоя.""" + + settings: AuthSettings + token_client: AsyncTokenClient | None = None + alternate_token_client: AsyncAlternateTokenClient | None = None + autoteka_token_client: AsyncTokenClient | None = None + token_fetcher: AsyncTokenFetcher | None = None + _cache: TokenCache = field(default_factory=TokenCache, init=False, repr=False) + _refresh_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False) + _autoteka_refresh_lock: asyncio.Lock = field( + default_factory=asyncio.Lock, + init=False, + repr=False, + ) + + async def get_access_token(self) -> str: + """Возвращает валидный access token, обновляя кэш при необходимости.""" + + now = datetime.now(UTC) + if not self._cache.access_is_fresh(now): + async with self._refresh_lock: + now = datetime.now(UTC) + if not self._cache.access_is_fresh(now): + token_response = await self.refresh_access_token() + return token_response.access_token.value + access_token = self._cache.access_token + if access_token is None: + raise AuthenticationError("Не удалось получить OAuth access token.") + return access_token.value + + async def refresh_access_token(self) -> TokenResponse: + """Принудительно обновляет токен через refresh token или client credentials.""" + + token_response = await self._fetch_token_response() + self._cache.access_token = token_response.access_token + if token_response.refresh_token is not None: + self._cache.refresh_token = token_response.refresh_token + return token_response + + def invalidate_token(self) -> None: + """Сбрасывает закэшированный токен после `401 Unauthorized`.""" + + self._cache.reset_access() + + async def aclose(self) -> None: + """Закрывает внутренние HTTP-клиенты provider-а.""" + + for client in (self.token_client, self.alternate_token_client, self.autoteka_token_client): + if client is not None: + await client.aclose() + + async def get_autoteka_access_token(self) -> str: + """Возвращает отдельный access token для flow Автотеки.""" + + now = datetime.now(UTC) + if not self._cache.autoteka_is_fresh(now): + async with self._autoteka_refresh_lock: + now = datetime.now(UTC) + if not self._cache.autoteka_is_fresh(now): + token_response = ( + await self._get_autoteka_token_client().request_autoteka_client_credentials_token( + ClientCredentialsRequest( + client_id=self.settings.autoteka_client_id + or self.settings.client_id + or "", + client_secret=self.settings.autoteka_client_secret + or self.settings.client_secret + or "", + scope=self.settings.autoteka_scope, + ) + ) + ) + self._cache.autoteka_access_token = token_response.access_token + token = self._cache.autoteka_access_token + if token is None: + raise AuthenticationError("Не удалось получить OAuth access token для Автотеки.") + return token.value + + def token_flow(self) -> AsyncTokenClient: + """Возвращает canonical async token client для low-level OAuth операций.""" + + return self._get_token_client() + + def alternate_token_flow(self) -> AsyncAlternateTokenClient: + """Возвращает дополнительный async token client для альтернативного `/token` flow.""" + + return self._get_alternate_token_client() + + async def _fetch_token_response(self) -> TokenResponse: + """Fetch token response.""" + if self.token_fetcher is not None: + token_response = await self.token_fetcher(self.settings) + if isinstance(token_response, AccessToken): + return TokenResponse(access_token=token_response) + return token_response + if self._cache.refresh_token: + return await self._get_token_client().request_refresh_token( + RefreshTokenRequest( + client_id=self._require_client_id(), + client_secret=self._require_client_secret(), + refresh_token=self._cache.refresh_token, + scope=self.settings.scope, + ) + ) + if self.settings.refresh_token: + return await self._get_token_client().request_refresh_token( + RefreshTokenRequest( + client_id=self._require_client_id(), + client_secret=self._require_client_secret(), + refresh_token=self.settings.refresh_token, + scope=self.settings.scope, + ) + ) + return await self._get_token_client().request_client_credentials_token( + ClientCredentialsRequest( + client_id=self._require_client_id(), + client_secret=self._require_client_secret(), + scope=self.settings.scope, + ) + ) + + def _get_token_client(self) -> AsyncTokenClient: + """Return token client.""" + if self.token_client is None: + self.token_client = AsyncTokenClient(self.settings) + if self.token_client is None: + raise ConfigurationError("Не удалось инициализировать OAuth token client.") + return self.token_client + + def _get_alternate_token_client(self) -> AsyncAlternateTokenClient: + """Return alternate token client.""" + if self.alternate_token_client is None: + self.alternate_token_client = AsyncAlternateTokenClient(self.settings) + if self.alternate_token_client is None: + raise ConfigurationError("Не удалось инициализировать alternate OAuth token client.") + return self.alternate_token_client + + def _get_autoteka_token_client(self) -> AsyncTokenClient: + """Return autoteka token client.""" + if self.autoteka_token_client is None: + self.autoteka_token_client = AsyncTokenClient( + self.settings, + token_url=self.settings.autoteka_token_url, + ) + if self.autoteka_token_client is None: + raise ConfigurationError("Не удалось инициализировать OAuth token client для Автотеки.") + return self.autoteka_token_client + + def _require_client_id(self) -> str: + """Validate required client id.""" + if self.settings.client_id is None: + raise AuthenticationError("Для OAuth flow не задан `client_id`.") + return self.settings.client_id + + def _require_client_secret(self) -> str: + """Validate required client secret.""" + if self.settings.client_secret is None: + raise AuthenticationError("Для OAuth flow не задан `client_secret`.") + return self.settings.client_secret + + +__all__ = ("AsyncAuthProvider", "AsyncTokenFetcher") diff --git a/avito/auth/async_token_client.py b/avito/auth/async_token_client.py new file mode 100644 index 0000000..4f8e0f5 --- /dev/null +++ b/avito/auth/async_token_client.py @@ -0,0 +1,195 @@ +"""Async OAuth token-flow clients.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import httpx + +from avito.auth._cache import map_token_response +from avito.auth.models import ClientCredentialsRequest, RefreshTokenRequest, TokenResponse +from avito.auth.provider import CLIENT_CREDENTIALS_GRANT, REFRESH_TOKEN_GRANT +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core.exceptions import AuthenticationError, AvitoError +from avito.core.swagger import swagger_operation +from avito.core.types import RequestContext + + +@dataclass(slots=True, frozen=True) +class AsyncTokenClient: + """Служебный async-клиент для canonical OAuth token endpoint.""" + + __swagger_domain__ = "auth" + + settings: AuthSettings + token_url: str | None = None + client: httpx.AsyncClient | None = None + sdk_settings: AvitoSettings | None = None + + async def aclose(self) -> None: + """Закрывает выделенный HTTP-клиент, если он был передан снаружи.""" + + if self.client is not None: + await self.client.aclose() + + @swagger_operation( + "POST", + "/token", + spec="Авторизация.json", + operation_id="getAccessToken", + method_args={"request": "body"}, + variant="async", + ) + async def request_client_credentials_token( + self, + request: ClientCredentialsRequest, + ) -> TokenResponse: + """Запрашивает access token по flow `client_credentials`.""" + + payload: dict[str, str] = { + "grant_type": CLIENT_CREDENTIALS_GRANT, + "client_id": request.client_id, + "client_secret": request.client_secret, + } + if request.scope is not None: + payload["scope"] = request.scope + return await self._request_token(payload) + + @swagger_operation( + "POST", + "/token", + spec="Автотека.json", + operation_id="getAccessToken", + method_args={"request": "query.grant_type"}, + variant="async", + ) + async def request_autoteka_client_credentials_token( + self, + request: ClientCredentialsRequest, + ) -> TokenResponse: + """Запрашивает access token по отдельному flow Автотеки.""" + + return await self.request_client_credentials_token(request) + + async def request_refresh_token(self, request: RefreshTokenRequest) -> TokenResponse: + """Запрашивает новый access token по flow `refresh_token`.""" + + payload: dict[str, str] = { + "grant_type": REFRESH_TOKEN_GRANT, + "client_id": request.client_id, + "client_secret": request.client_secret, + "refresh_token": request.refresh_token, + } + if request.scope is not None: + payload["scope"] = request.scope + return await self._request_token(payload) + + async def _request_token(self, payload: dict[str, str]) -> TokenResponse: + """Run the request token helper.""" + from avito.core.async_transport import AsyncTransport + + transport = AsyncTransport( + self.sdk_settings or AvitoSettings(auth=self.settings), + auth_provider=None, + client=self.client, + ) + try: + response = await transport.request( + "POST", + self.token_url or self.settings.token_url, + context=RequestContext("auth.oauth_token", requires_auth=False), + data=payload, + headers={"Accept": "application/json"}, + ) + except AuthenticationError: + raise + except AvitoError as exc: + raise AuthenticationError( + exc.message, + status_code=exc.status_code, + error_code=exc.error_code, + operation=exc.operation, + attempt=exc.attempt, + method=exc.method, + endpoint=exc.endpoint, + details=exc.details, + retry_after=exc.retry_after, + request_id=exc.request_id, + metadata=exc.metadata, + payload=exc.payload, + headers=exc.headers, + ) from exc + finally: + if self.client is None: + await transport.aclose() + + try: + payload_object = response.json() + except ValueError as exc: + raise AuthenticationError( + "OAuth-сервер вернул некорректный JSON.", + status_code=response.status_code, + payload=response.text, + headers=dict(response.headers), + ) from exc + return map_token_response(payload_object) + + +@dataclass(slots=True, frozen=True) +class AsyncAlternateTokenClient: + """Служебный async-клиент для альтернативного token endpoint из swagger.""" + + __swagger_domain__ = "auth" + + settings: AuthSettings + client: httpx.AsyncClient | None = None + sdk_settings: AvitoSettings | None = None + + async def aclose(self) -> None: + """Закрывает выделенный HTTP-клиент альтернативного token flow.""" + + if self.client is not None: + await self.client.aclose() + + @swagger_operation( + "POST", + "/token\u200e", + spec="Авторизация.json", + operation_id="getAccessTokenAuthorizationCode", + method_args={"request": "body"}, + variant="async", + ) + async def request_client_credentials_token( + self, + request: ClientCredentialsRequest, + ) -> TokenResponse: + """Запрашивает токен через альтернативный canonical `/token`.""" + + return await AsyncTokenClient( + self.settings, + token_url=self.settings.alternate_token_url, + client=self.client, + sdk_settings=self.sdk_settings, + ).request_client_credentials_token(request) + + @swagger_operation( + "POST", + "/token\u200e\u200e", + spec="Авторизация.json", + operation_id="refreshAccessTokenAuthorizationCode", + method_args={"request": "body"}, + variant="async", + ) + async def request_refresh_token(self, request: RefreshTokenRequest) -> TokenResponse: + """Обновляет токен через альтернативный canonical `/token`.""" + + return await AsyncTokenClient( + self.settings, + token_url=self.settings.alternate_token_url, + client=self.client, + sdk_settings=self.sdk_settings, + ).request_refresh_token(request) + + +__all__ = ("AsyncAlternateTokenClient", "AsyncTokenClient") diff --git a/avito/auth/provider.py b/avito/auth/provider.py index cbf946a..985b8a3 100644 --- a/avito/auth/provider.py +++ b/avito/auth/provider.py @@ -3,11 +3,12 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from typing import Protocol import httpx +from avito.auth._cache import TokenCache, map_token_response from avito.auth.models import ( AccessToken, ClientCredentialsRequest, @@ -20,7 +21,6 @@ AuthenticationError, AvitoError, ConfigurationError, - ResponseMappingError, ) from avito.core.swagger import swagger_operation from avito.core.transport import Transport @@ -32,42 +32,15 @@ _UNSET = object() -def _map_token_response(payload: object, *, now: datetime | None = None) -> TokenResponse: - if not isinstance(payload, dict): - raise ResponseMappingError("OAuth-ответ должен быть JSON-объектом.", payload=payload) - - access_token = payload.get("access_token") - if not isinstance(access_token, str) or not access_token: - raise ResponseMappingError("В OAuth-ответе отсутствует `access_token`.", payload=payload) - - raw_expires_in = payload.get("expires_in", 0) - if not isinstance(raw_expires_in, int | float) or isinstance(raw_expires_in, bool): - raise ResponseMappingError("Поле `expires_in` должно быть числом.", payload=payload) - - refresh_token = payload.get("refresh_token") - if refresh_token is not None and not isinstance(refresh_token, str): - raise ResponseMappingError("Поле `refresh_token` должно быть строкой.", payload=payload) - - token_type = payload.get("token_type", "Bearer") - if not isinstance(token_type, str): - raise ResponseMappingError("Поле `token_type` должно быть строкой.", payload=payload) - - issued_at = now or datetime.now(UTC) - return TokenResponse( - access_token=AccessToken( - value=access_token, - expires_at=issued_at + timedelta(seconds=raw_expires_in), - token_type=token_type, - ), - refresh_token=refresh_token, - scope=payload.get("scope") if isinstance(payload.get("scope"), str) else None, - ) +_map_token_response = map_token_response class TokenFetcher(Protocol): """Контракт получения нового access token из внешнего источника.""" - def __call__(self, settings: AuthSettings) -> TokenResponse: ... + def __call__(self, settings: AuthSettings) -> TokenResponse: + """Fetch a token payload.""" + ... @dataclass(slots=True) @@ -79,9 +52,40 @@ class AuthProvider: alternate_token_client: AlternateTokenClient | None = None autoteka_token_client: TokenClient | None = None token_fetcher: TokenFetcher | None = None - _access_token: AccessToken | None = field(default=None, init=False, repr=False) - _refresh_token: str | None = field(default=None, init=False, repr=False) - _autoteka_access_token: AccessToken | None = field(default=None, init=False, repr=False) + _cache: TokenCache = field(default_factory=TokenCache, init=False, repr=False) + + @property + def _access_token(self) -> AccessToken | None: + """Legacy private accessor kept for existing tests.""" + + return self._cache.access_token + + @_access_token.setter + def _access_token(self, value: AccessToken | None) -> None: + """Run the access token helper.""" + self._cache.access_token = value + + @property + def _refresh_token(self) -> str | None: + """Legacy private accessor kept for existing tests.""" + + return self._cache.refresh_token + + @_refresh_token.setter + def _refresh_token(self, value: str | None) -> None: + """Run the refresh token helper.""" + self._cache.refresh_token = value + + @property + def _autoteka_access_token(self) -> AccessToken | None: + """Legacy private accessor kept for existing tests.""" + + return self._cache.autoteka_access_token + + @_autoteka_access_token.setter + def _autoteka_access_token(self, value: AccessToken | None) -> None: + """Run the autoteka access token helper.""" + self._cache.autoteka_access_token = value def get_access_token(self) -> str: """Возвращает валидный access token, обновляя кэш при необходимости.""" @@ -146,6 +150,7 @@ def alternate_token_flow(self) -> AlternateTokenClient: return self._get_alternate_token_client() def _fetch_token_response(self) -> TokenResponse: + """Fetch token response.""" if self.token_fetcher is not None: token_response = self.token_fetcher(self.settings) if isinstance(token_response, AccessToken): @@ -184,6 +189,7 @@ def _update_tokens( refresh_token: str | None | object = _UNSET, autoteka_access_token: AccessToken | None | object = _UNSET, ) -> None: + """Run the update tokens helper.""" if access_token is not _UNSET: self._access_token = access_token if isinstance(access_token, AccessToken) else None if refresh_token is not _UNSET: @@ -195,6 +201,7 @@ def _update_tokens( ) def _get_token_client(self) -> TokenClient: + """Return token client.""" if self.token_client is None: self.token_client = TokenClient(self.settings) token_client = self.token_client @@ -203,6 +210,7 @@ def _get_token_client(self) -> TokenClient: return token_client def _get_alternate_token_client(self) -> AlternateTokenClient: + """Return alternate token client.""" if self.alternate_token_client is None: self.alternate_token_client = AlternateTokenClient(self.settings) alternate_token_client = self.alternate_token_client @@ -211,6 +219,7 @@ def _get_alternate_token_client(self) -> AlternateTokenClient: return alternate_token_client def _get_autoteka_token_client(self) -> TokenClient: + """Return autoteka token client.""" if self.autoteka_token_client is None: self.autoteka_token_client = TokenClient( self.settings, @@ -222,11 +231,13 @@ def _get_autoteka_token_client(self) -> TokenClient: return autoteka_token_client def _require_client_id(self) -> str: + """Validate required client id.""" if self.settings.client_id is None: raise AuthenticationError("Для OAuth flow не задан `client_id`.") return self.settings.client_id def _require_client_secret(self) -> str: + """Validate required client secret.""" if self.settings.client_secret is None: raise AuthenticationError("Для OAuth flow не задан `client_secret`.") return self.settings.client_secret @@ -300,6 +311,7 @@ def request_refresh_token(self, request: RefreshTokenRequest) -> TokenResponse: return self._request_token(payload) def _request_token(self, payload: dict[str, str]) -> TokenResponse: + """Run the request token helper.""" transport = self._build_transport() try: response = transport.request( @@ -343,6 +355,7 @@ def _request_token(self, payload: dict[str, str]) -> TokenResponse: return _map_token_response(payload_object) def _build_transport(self) -> Transport: + """Build transport.""" return Transport( self.sdk_settings or AvitoSettings(auth=self.settings), auth_provider=None, diff --git a/avito/autoteka/__init__.py b/avito/autoteka/__init__.py index 9289dfb..92e024f 100644 --- a/avito/autoteka/__init__.py +++ b/avito/autoteka/__init__.py @@ -1,5 +1,12 @@ """Пакет autoteka.""" +from avito.autoteka.async_domain import ( + AsyncAutotekaMonitoring, + AsyncAutotekaReport, + AsyncAutotekaScoring, + AsyncAutotekaValuation, + AsyncAutotekaVehicle, +) from avito.autoteka.domain import ( AutotekaMonitoring, AutotekaReport, @@ -41,6 +48,11 @@ ) __all__ = ( + "AsyncAutotekaMonitoring", + "AsyncAutotekaReport", + "AsyncAutotekaScoring", + "AsyncAutotekaValuation", + "AsyncAutotekaVehicle", "AutotekaLeadEvent", "AutotekaLeadsResult", "AutotekaMonitoring", diff --git a/avito/autoteka/async_domain.py b/avito/autoteka/async_domain.py new file mode 100644 index 0000000..accb2fe --- /dev/null +++ b/avito/autoteka/async_domain.py @@ -0,0 +1,1250 @@ +"""Async-доменные объекты пакета autoteka.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from avito.autoteka.models import ( + AutotekaLeadsResult, + AutotekaPackageInfo, + AutotekaPreviewInfo, + AutotekaReportInfo, + AutotekaReportsResult, + AutotekaScoringInfo, + AutotekaSpecificationInfo, + AutotekaTeaserInfo, + AutotekaValuationInfo, + CatalogResolveRequest, + CatalogResolveResult, + ExternalItemPreviewRequest, + ItemIdRequest, + LeadsRequest, + MonitoringBucketRequest, + MonitoringBucketResult, + MonitoringEventsQuery, + MonitoringEventsResult, + PlateNumberRequest, + PreviewReportRequest, + RegNumberRequest, + TeaserCreateRequest, + ValuationBySpecificationRequest, + VehicleIdRequest, + VinRequest, +) +from avito.autoteka.operations import ( + ADD_MONITORING_BUCKET, + CATALOG_RESOLVE, + CREATE_PREVIEW_BY_EXTERNAL_ITEM, + CREATE_PREVIEW_BY_ITEM_ID, + CREATE_PREVIEW_BY_REG_NUMBER, + CREATE_PREVIEW_BY_VIN, + CREATE_REPORT, + CREATE_REPORT_BY_VEHICLE_ID, + CREATE_SCORING_BY_VEHICLE_ID, + CREATE_SPECIFICATION_BY_PLATE_NUMBER, + CREATE_SPECIFICATION_BY_VEHICLE_ID, + CREATE_SYNC_REPORT_BY_REG_NUMBER, + CREATE_SYNC_REPORT_BY_VIN, + CREATE_TEASER, + DELETE_MONITORING_BUCKET, + GET_ACTIVE_PACKAGE, + GET_LEADS, + GET_MONITORING_REG_ACTIONS, + GET_PREVIEW, + GET_REPORT, + GET_SCORING_BY_ID, + GET_SPECIFICATION_BY_ID, + GET_TEASER, + GET_VALUATION_BY_SPECIFICATION, + LIST_REPORTS, + REMOVE_MONITORING_BUCKET, +) +from avito.core import ApiTimeouts, RetryOverride, ValidationError +from avito.core.async_transport import AsyncTransport +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation + + +async def _autoteka_headers(transport: AsyncTransport) -> dict[str, str]: + """Run the autoteka headers helper.""" + auth_provider = transport.auth_provider + if auth_provider is None: + return {} + return {"Authorization": f"Bearer {await auth_provider.get_autoteka_access_token()}"} + + +@dataclass(slots=True, frozen=True) +class AsyncAutotekaVehicle(AsyncDomainObject): + """Доменный объект превью, спецификаций, тизеров и каталога.""" + + __swagger_domain__ = "autoteka" + __sdk_factory__ = "autoteka_vehicle" + __sdk_factory_args__ = {"vehicle_id": "path.vehicle_id"} + + vehicle_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/autoteka/v1/catalogs/resolve", + spec="Автотека.json", + operation_id="catalogsResolve", + variant="async", + method_args={"brand_id": "body.fieldsValueIds[].valueId"}, + ) + async def resolve_catalog( + self, + *, + brand_id: int, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CatalogResolveResult: + """Актуализирует параметры автокаталога. + + Аргументы: + brand_id: идентифицирует марку автомобиля в каталоге. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CatalogResolveResult` с актуализированными параметрами каталога. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CATALOG_RESOLVE, + request=CatalogResolveRequest(brand_id=brand_id), + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/get-leads", + spec="Автотека.json", + operation_id="getLeads", + variant="async", + method_args={"subscription_id": "body.subscriptionId", "limit": "body.limit"}, + ) + async def get_leads( + self, + *, + subscription_id: int, + limit: int, + last_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaLeadsResult: + """Возвращает leads для автомобилей Автотеки. + + Аргументы: + subscription_id: идентифицирует подписку Сигнала. + limit: ограничивает размер возвращаемой выборки. + last_id: задает последний прочитанный идентификатор для постраничной выдачи. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaLeadsResult` с типизированными данными ответа API. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_LEADS, + request=LeadsRequest(subscription_id=subscription_id, limit=limit, last_id=last_id), + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/previews", + spec="Автотека.json", + operation_id="postPreviewByVin", + variant="async", + method_args={"vin": "body.vin"}, + ) + async def create_preview_by_vin( + self, + *, + vin: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaPreviewInfo: + """Создает preview автомобиля Автотеки по VIN. + + Аргументы: + vin: передает VIN автомобиля. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaPreviewInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_PREVIEW_BY_VIN, + request=VinRequest(vin=vin), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoteka/v1/previews/{previewId}", + spec="Автотека.json", + operation_id="getPreview", + variant="async", + ) + async def get_preview( + self, + *, + preview_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaPreviewInfo: + """Возвращает preview для автомобилей Автотеки. + + Аргументы: + preview_id: идентифицирует preview Автотеки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaPreviewInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_PREVIEW, + path_params={"previewId": preview_id or self._require_vehicle_id("preview_id")}, + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/request-preview-by-external-item", + spec="Автотека.json", + operation_id="postPreviewByExternalItem", + variant="async", + method_args={"item_id": "body.itemId", "site": "body.site"}, + ) + async def create_preview_by_external_item( + self, + *, + item_id: str, + site: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaPreviewInfo: + """Создает preview автомобиля Автотеки по внешнему объявлению. + + Аргументы: + item_id: идентифицирует объявление Авито. + site: задает площадку внешнего объявления. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaPreviewInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_PREVIEW_BY_EXTERNAL_ITEM, + request=ExternalItemPreviewRequest(item_id=item_id, site=site), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/request-preview-by-item-id", + spec="Автотека.json", + operation_id="postPreviewByItemId", + variant="async", + method_args={"item_id": "body.item_id"}, + ) + async def create_preview_by_item_id( + self, + *, + item_id: int, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaPreviewInfo: + """Создает preview автомобиля Автотеки по объявлению Авито. + + Аргументы: + item_id: идентифицирует объявление Авито. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaPreviewInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_PREVIEW_BY_ITEM_ID, + request=ItemIdRequest(item_id=item_id), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/request-preview-by-regnumber", + spec="Автотека.json", + operation_id="postPreviewByRegNumber", + variant="async", + method_args={"reg_number": "body.reg_number"}, + ) + async def create_preview_by_reg_number( + self, + *, + reg_number: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaPreviewInfo: + """Создает preview автомобиля Автотеки по госномеру. + + Аргументы: + reg_number: передает государственный номер автомобиля. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaPreviewInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_PREVIEW_BY_REG_NUMBER, + request=RegNumberRequest(reg_number=reg_number), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/specifications/by-plate-number", + spec="Автотека.json", + operation_id="specificationByPlateNumber", + variant="async", + method_args={"plate_number": "body.plate_number"}, + ) + async def create_specification_by_plate_number( + self, + *, + plate_number: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaSpecificationInfo: + """Создает спецификацию автомобиля Автотеки по номерному знаку. + + Аргументы: + plate_number: передает номерной знак автомобиля. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaSpecificationInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_SPECIFICATION_BY_PLATE_NUMBER, + request=PlateNumberRequest(plate_number=plate_number), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/specifications/by-vehicle-id", + spec="Автотека.json", + operation_id="specificationByVehicleId", + variant="async", + method_args={"vehicle_id": "body.vehicle_id"}, + ) + async def create_specification_by_vehicle_id( + self, + *, + vehicle_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaSpecificationInfo: + """Создает спецификацию автомобиля Автотеки по vehicle_id. + + Аргументы: + vehicle_id: идентифицирует автомобиль в Автотеке. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaSpecificationInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_SPECIFICATION_BY_VEHICLE_ID, + request=VehicleIdRequest(vehicle_id=vehicle_id), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoteka/v1/specifications/specification/{specificationID}", + spec="Автотека.json", + operation_id="specificationGetById", + variant="async", + ) + async def get_specification_by_id( + self, + *, + specification_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaSpecificationInfo: + """Возвращает спецификацию автомобиля Автотеки по идентификатору. + + Аргументы: + specification_id: идентифицирует спецификацию автомобиля. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaSpecificationInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_SPECIFICATION_BY_ID, + path_params={ + "specificationID": specification_id or self._require_vehicle_id("specification_id") + }, + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/teasers", + spec="Автотека.json", + operation_id="postTeaser", + variant="async", + method_args={"vehicle_id": "body.vehicle_id"}, + ) + async def create_teaser( + self, + *, + vehicle_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaTeaserInfo: + """Создает тизер автомобиля Автотеки. + + Аргументы: + vehicle_id: идентифицирует автомобиль в Автотеке. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaTeaserInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_TEASER, + request=TeaserCreateRequest(vehicle_id=vehicle_id), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoteka/v1/teasers/{teaser_id}", + spec="Автотека.json", + operation_id="getTeaser", + variant="async", + ) + async def get_teaser( + self, + *, + teaser_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaTeaserInfo: + """Возвращает teaser для автомобилей Автотеки. + + Аргументы: + teaser_id: идентифицирует тизер Автотеки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaTeaserInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_TEASER, + path_params={"teaser_id": teaser_id or self._require_vehicle_id("teaser_id")}, + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + def _require_vehicle_id(self, field_name: str) -> str: + """Validate required vehicle id.""" + if self.vehicle_id is None: + raise ValidationError(f"Для операции требуется `{field_name}`.") + return str(self.vehicle_id) + + +@dataclass(slots=True, frozen=True) +class AsyncAutotekaReport(AsyncDomainObject): + """Доменный объект отчетов и пакетов Автотеки.""" + + __swagger_domain__ = "autoteka" + __sdk_factory__ = "autoteka_report" + __sdk_factory_args__ = {"report_id": "path.report_id"} + + report_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/autoteka/v1/packages/active_package", + spec="Автотека.json", + operation_id="getActivePackage", + variant="async", + ) + async def get_active_package( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutotekaPackageInfo: + """Возвращает active package для отчетов Автотеки. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaPackageInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_ACTIVE_PACKAGE, + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/reports", + spec="Автотека.json", + operation_id="postReport", + variant="async", + method_args={"preview_id": "body.preview_id"}, + ) + async def create_report( + self, + *, + preview_id: int, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaReportInfo: + """Создает отчет Автотеки по preview. + + Аргументы: + preview_id: идентифицирует preview Автотеки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaReportInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_REPORT, + request=PreviewReportRequest(preview_id=preview_id), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/reports-by-vehicle-id", + spec="Автотека.json", + operation_id="postReportByVehicleId", + variant="async", + method_args={"vehicle_id": "body.vehicle_id"}, + ) + async def create_report_by_vehicle_id( + self, + *, + vehicle_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaReportInfo: + """Создает отчет Автотеки по vehicle_id. + + Аргументы: + vehicle_id: идентифицирует автомобиль в Автотеке. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaReportInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_REPORT_BY_VEHICLE_ID, + request=VehicleIdRequest(vehicle_id=vehicle_id), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoteka/v1/reports/list", + spec="Автотека.json", + operation_id="getReportList", + variant="async", + ) + async def list_reports( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> AutotekaReportsResult: + """Возвращает список reports для отчетов Автотеки. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaReportsResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + LIST_REPORTS, + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoteka/v1/reports/{report_id}", + spec="Автотека.json", + operation_id="getReport", + variant="async", + ) + async def get_report( + self, + *, + report_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaReportInfo: + """Возвращает report для отчетов Автотеки. + + Аргументы: + report_id: идентифицирует отчет Автотеки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaReportInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_REPORT, + path_params={"report_id": report_id or self._require_report_id()}, + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/sync/create-by-regnumber", + spec="Автотека.json", + operation_id="postSyncCreateReportByRegNumber", + variant="async", + method_args={"reg_number": "body.reg_number"}, + ) + async def create_sync_report_by_reg_number( + self, + *, + reg_number: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaReportInfo: + """Создает синхронный отчет Автотеки по госномеру. + + Аргументы: + reg_number: передает государственный номер автомобиля. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaReportInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_SYNC_REPORT_BY_REG_NUMBER, + request=RegNumberRequest(reg_number=reg_number), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/sync/create-by-vin", + spec="Автотека.json", + operation_id="postSyncCreateReportByVin", + variant="async", + method_args={"vin": "body.vin"}, + ) + async def create_sync_report_by_vin( + self, + *, + vin: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaReportInfo: + """Создает синхронный отчет Автотеки по VIN. + + Аргументы: + vin: передает VIN автомобиля. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaReportInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_SYNC_REPORT_BY_VIN, + request=VinRequest(vin=vin), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + def _require_report_id(self) -> str: + """Validate required report id.""" + if self.report_id is None: + raise ValidationError("Для операции требуется `report_id`.") + return str(self.report_id) + + +@dataclass(slots=True, frozen=True) +class AsyncAutotekaMonitoring(AsyncDomainObject): + """Доменный объект мониторинга Автотеки.""" + + __swagger_domain__ = "autoteka" + __sdk_factory__ = "autoteka_monitoring" + + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/autoteka/v1/monitoring/bucket/add", + spec="Автотека.json", + operation_id="monitoringBucketAdd", + variant="async", + method_args={"vehicles": "body.data"}, + ) + async def create_monitoring_bucket_add( + self, + *, + vehicles: list[str], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MonitoringBucketResult: + """Создает monitoring bucket add для мониторинга Автотеки. + + Аргументы: + vehicles: передает автомобили для добавления в мониторинг. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MonitoringBucketResult` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + ADD_MONITORING_BUCKET, + request=MonitoringBucketRequest(vehicles=vehicles), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/monitoring/bucket/delete", + spec="Автотека.json", + operation_id="monitoringBucketDelete", + variant="async", + ) + async def delete_bucket( + self, + *, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MonitoringBucketResult: + """Очищает bucket мониторинга. + + Аргументы: + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MonitoringBucketResult` со статусом операции над bucket мониторинга. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + DELETE_MONITORING_BUCKET, + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autoteka/v1/monitoring/bucket/remove", + spec="Автотека.json", + operation_id="monitoringBucketRemove", + variant="async", + method_args={"vehicles": "body.data"}, + ) + async def remove_bucket( + self, + *, + vehicles: list[str], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MonitoringBucketResult: + """Удаляет автомобили из bucket мониторинга. + + Аргументы: + vehicles: передает идентификаторы автомобилей для удаления из bucket. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MonitoringBucketResult` со статусом операции над bucket мониторинга. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + REMOVE_MONITORING_BUCKET, + request=MonitoringBucketRequest(vehicles=vehicles), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoteka/v1/monitoring/get-reg-actions", + spec="Автотека.json", + operation_id="monitoringGetRegActions", + variant="async", + ) + async def get_monitoring_reg_actions( + self, + *, + limit: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MonitoringEventsResult: + """Возвращает monitoring reg actions для мониторинга Автотеки. + + Аргументы: + limit: ограничивает размер возвращаемой выборки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MonitoringEventsResult` с типизированными данными ответа API. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_MONITORING_REG_ACTIONS, + query=MonitoringEventsQuery(limit=limit), + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncAutotekaScoring(AsyncDomainObject): + """Доменный объект скоринга рисков.""" + + __swagger_domain__ = "autoteka" + __sdk_factory__ = "autoteka_scoring" + __sdk_factory_args__ = {"scoring_id": "path.scoring_id"} + + scoring_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/autoteka/v1/scoring/by-vehicle-id", + spec="Автотека.json", + operation_id="scoringByVehicleId", + variant="async", + method_args={"vehicle_id": "body.vehicle_id"}, + ) + async def create_scoring_by_vehicle_id( + self, + *, + vehicle_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaScoringInfo: + """Создает расчет скоринга Автотеки по vehicle_id. + + Аргументы: + vehicle_id: идентифицирует автомобиль в Автотеке. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaScoringInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_SCORING_BY_VEHICLE_ID, + request=VehicleIdRequest(vehicle_id=vehicle_id), + headers=await _autoteka_headers(self.transport), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/autoteka/v1/scoring/{scoring_id}", + spec="Автотека.json", + operation_id="scoringGetById", + variant="async", + ) + async def get_scoring_by_id( + self, + *, + scoring_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaScoringInfo: + """Возвращает расчет скоринга Автотеки по идентификатору. + + Аргументы: + scoring_id: идентифицирует расчет скоринга. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaScoringInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_SCORING_BY_ID, + path_params={"scoring_id": scoring_id or self._require_scoring_id()}, + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + def _require_scoring_id(self) -> str: + """Validate required scoring id.""" + if self.scoring_id is None: + raise ValidationError("Для операции требуется `scoring_id`.") + return str(self.scoring_id) + + +@dataclass(slots=True, frozen=True) +class AsyncAutotekaValuation(AsyncDomainObject): + """Доменный объект оценки автомобиля.""" + + __swagger_domain__ = "autoteka" + __sdk_factory__ = "autoteka_valuation" + + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/autoteka/v1/valuation/by-specification", + spec="Автотека.json", + operation_id="valuationBySpecification", + variant="async", + method_args={ + "specification_id": "body.specification.brand.valueId", + "mileage": "body.mileage", + }, + ) + async def get_valuation_by_specification( + self, + *, + specification_id: int, + mileage: int, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutotekaValuationInfo: + """Возвращает оценку автомобиля Автотеки по спецификации. + + Аргументы: + specification_id: идентифицирует спецификацию автомобиля. + mileage: передает пробег автомобиля. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutotekaValuationInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_VALUATION_BY_SPECIFICATION, + request=ValuationBySpecificationRequest( + specification_id=specification_id, + mileage=mileage, + ), + headers=await _autoteka_headers(self.transport), + timeout=timeout, + retry=retry, + ) + + +__all__ = ( + "AsyncAutotekaMonitoring", + "AsyncAutotekaReport", + "AsyncAutotekaScoring", + "AsyncAutotekaValuation", + "AsyncAutotekaVehicle", +) diff --git a/avito/client.py b/avito/client.py index 35133a7..7860087 100644 --- a/avito/client.py +++ b/avito/client.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Awaitable, Callable, Iterable from datetime import date, datetime from pathlib import Path from types import TracebackType @@ -58,6 +58,7 @@ def _default_summary_date_range( date_from: SummaryDate | None, date_to: SummaryDate | None, ) -> tuple[SummaryDate, SummaryDate]: + """Run the default summary date range helper.""" if date_from is not None and date_to is not None: return date_from, date_to today = date.today().isoformat() @@ -65,6 +66,7 @@ def _default_summary_date_range( def _sum_optional_int(values: Iterable[int | None]) -> int | None: + """Run the sum optional int helper.""" resolved = [value for value in values if value is not None] if not resolved: return None @@ -72,6 +74,7 @@ def _sum_optional_int(values: Iterable[int | None]) -> int | None: def _sum_optional_float(values: Iterable[float | None]) -> float | None: + """Run the sum optional float helper.""" resolved = [value for value in values if value is not None] if not resolved: return None @@ -79,6 +82,7 @@ def _sum_optional_float(values: Iterable[float | None]) -> float | None: def _summary_unavailable_section(section: str, error: AvitoError) -> SummaryUnavailableSection: + """Run the summary unavailable section helper.""" return SummaryUnavailableSection( section=section, operation=error.operation, @@ -92,12 +96,24 @@ def _safe_summary[SummaryT]( section: str, factory: Callable[[], SummaryT], ) -> tuple[SummaryT | None, list[SummaryUnavailableSection]]: + """Return a safe summary.""" try: return factory(), [] except AvitoError as error: return None, [_summary_unavailable_section(section, error)] +async def _safe_summary_async[SummaryT]( + section: str, + factory: Callable[[], Awaitable[SummaryT]], +) -> tuple[SummaryT | None, list[SummaryUnavailableSection]]: + """Return a safe summary async.""" + try: + return await factory(), [] + except AvitoError as error: + return None, [_summary_unavailable_section(section, error)] + + class AvitoClient: """Единственная публичная точка входа SDK с фабриками доменных объектов. @@ -118,6 +134,7 @@ def __init__( client_id: str | None = None, client_secret: str | None = None, ) -> None: + """Initialize AvitoClient.""" if client_id is not None or client_secret is not None: from avito.auth.settings import AuthSettings @@ -142,6 +159,7 @@ def _from_transport( transport: Transport, auth_provider: AuthProvider, ) -> AvitoClient: + """Run the from transport helper.""" client = cls.__new__(cls) client._closed = False client._settings = settings @@ -555,6 +573,7 @@ def __exit__( self.close() def _build_auth_provider(self) -> AuthProvider: + """Build auth provider.""" return AuthProvider( self.settings.auth, token_client=TokenClient(self.settings.auth, sdk_settings=self.settings), @@ -570,14 +589,17 @@ def _build_auth_provider(self) -> AuthProvider: ) def _ensure_open(self) -> None: + """Ensure open.""" if self._closed: raise ClientClosedError("Клиент закрыт; создайте новый AvitoClient.") def _require_transport(self) -> Transport: + """Validate required transport.""" self._ensure_open() return self.transport def _resolve_user_id(self, user_id: int | str | None = None) -> int: + """Resolve user id.""" return Account(self._require_transport(), user_id=user_id)._resolve_user_id(user_id) def account(self, user_id: int | str | None = None) -> Account: diff --git a/avito/core/__init__.py b/avito/core/__init__.py index 0f3ee50..5f2696a 100644 --- a/avito/core/__init__.py +++ b/avito/core/__init__.py @@ -1,6 +1,8 @@ """Пакет общей инфраструктуры SDK.""" -from avito.core.domain import DomainObject +from avito.core.async_pagination import AsyncPaginatedList, AsyncPaginator +from avito.core.async_transport import AsyncTransport +from avito.core.domain import AsyncDomainObject, DomainObject from avito.core.exceptions import ( AuthenticationError, AuthorizationError, @@ -17,7 +19,13 @@ ) from avito.core.fields import api_field from avito.core.models import ApiErrorPayload, ApiModel, EmptyRequest, RequestModel -from avito.core.operations import EmptyResponse, OperationExecutor, OperationSpec +from avito.core.operations import ( + AsyncOperationExecutor, + AsyncOperationTransport, + EmptyResponse, + OperationExecutor, + OperationSpec, +) from avito.core.pagination import PaginatedList, Paginator from avito.core.payload import JsonReader from avito.core.retries import RetryDecision, RetryPolicy @@ -37,6 +45,12 @@ "ApiTimeouts", "ApiModel", "ApiErrorPayload", + "AsyncDomainObject", + "AsyncOperationExecutor", + "AsyncOperationTransport", + "AsyncPaginatedList", + "AsyncPaginator", + "AsyncTransport", "AuthenticationError", "AuthorizationError", "AvitoError", diff --git a/avito/core/_async_rate_limit.py b/avito/core/_async_rate_limit.py new file mode 100644 index 0000000..8c7c0c5 --- /dev/null +++ b/avito/core/_async_rate_limit.py @@ -0,0 +1,47 @@ +"""Async rate limiter for the transport layer.""" + +from __future__ import annotations + +import asyncio +import time +from collections.abc import Awaitable, Callable, Mapping + +from avito.core._transport_shared import RateLimitState +from avito.core.retries import RetryPolicy + + +class AsyncRateLimiter: + """Async token bucket over shared `RateLimitState`.""" + + def __init__( + self, + policy: RetryPolicy, + *, + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, + ) -> None: + """Initialize AsyncRateLimiter.""" + self._clock = clock + self._sleep = sleep + self._state = RateLimitState.from_policy(policy, now=clock()) + self._lock = asyncio.Lock() + + async def acquire(self) -> float: + """Wait until a request may be sent and return the total delay.""" + + total_delay = 0.0 + async with self._lock: + while True: + delay = self._state.compute_delay(self._clock()) + if delay <= 0.0: + return total_delay + await self._sleep(delay) + total_delay += delay + + def observe_response(self, *, headers: Mapping[str, str]) -> None: + """Update cooldown from response headers.""" + + self._state.observe_response(now=self._clock(), headers=headers) + + +__all__ = ("AsyncRateLimiter",) diff --git a/avito/core/_transport_shared.py b/avito/core/_transport_shared.py new file mode 100644 index 0000000..89d8456 --- /dev/null +++ b/avito/core/_transport_shared.py @@ -0,0 +1,449 @@ +"""Shared pure helpers for sync and async transport implementations.""" + +from __future__ import annotations + +import importlib.metadata as importlib_metadata +import json +import platform +import time +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +from email.message import Message +from email.utils import parsedate_to_datetime +from io import BytesIO +from typing import cast +from urllib.parse import quote, urlsplit + +import httpx + +from avito.core.exceptions import ( + AuthenticationError, + AuthorizationError, + AvitoError, + ConflictError, + RateLimitError, + UnsupportedOperationError, + UpstreamApiError, + ValidationError, +) +from avito.core.retries import RetryDecision, RetryPolicy +from avito.core.types import ApiTimeouts, RequestContext + +QueryScalar = str | int | float | bool | None +QueryParamValue = QueryScalar | Sequence[QueryScalar] +QueryParams = Mapping[str, QueryParamValue] +FileValue = ( + BytesIO + | bytes + | str + | tuple[str | None, BytesIO | bytes | str] + | tuple[str | None, BytesIO | bytes | str, str | None] + | tuple[str | None, BytesIO | bytes | str, str | None, Mapping[str, str]] +) +RequestFiles = Mapping[str, FileValue] + +_MIN_RETRY_AFTER_SECONDS = 0.5 + + +@dataclass(slots=True) +class RateLimitState: + """Pure token-bucket state shared by sync and async rate limiters.""" + + enabled: bool + rate: float + capacity: int + tokens: float + updated_at: float + blocked_until: float = 0.0 + + @classmethod + def from_policy(cls, policy: RetryPolicy, *, now: float) -> RateLimitState: + """Build rate limit state from retry policy settings.""" + capacity = max(policy.rate_limit_burst, 0) + return cls( + enabled=policy.rate_limit_enabled, + rate=max(policy.rate_limit_requests_per_second, 0.0), + capacity=capacity, + tokens=float(capacity), + updated_at=now, + ) + + def compute_delay(self, now: float) -> float: + """Return required delay and reserve a token when it can proceed now.""" + + if not self.enabled or self.rate <= 0.0 or self.capacity <= 0: + return 0.0 + self._refill(now) + blocked_delay = max(self.blocked_until - now, 0.0) + if blocked_delay > 0.0: + return blocked_delay + if self.tokens >= 1.0: + self.tokens -= 1.0 + return 0.0 + return (1.0 - self.tokens) / self.rate + + def observe_response(self, *, now: float, headers: Mapping[str, str]) -> None: + """Update cooldown from upstream rate-limit headers.""" + + if not self.enabled or self.rate <= 0.0: + return + remaining = _get_header(headers, "x-ratelimit-remaining") + if remaining is None: + return + try: + remaining_count = int(remaining) + except ValueError: + return + if remaining_count <= 0: + self.blocked_until = max(self.blocked_until, now + 1.0 / self.rate) + self.tokens = min(self.tokens, 0.0) + + def _refill(self, now: float) -> None: + """Refill available rate limit tokens.""" + elapsed = max(now - self.updated_at, 0.0) + if elapsed > 0.0: + self.tokens = min(float(self.capacity), self.tokens + elapsed * self.rate) + self.updated_at = now + + +def build_httpx_timeout(timeouts: ApiTimeouts) -> httpx.Timeout: + """Convert SDK timeout config to `httpx.Timeout`.""" + + return httpx.Timeout( + connect=timeouts.connect, + read=timeouts.read, + write=timeouts.write, + pool=timeouts.pool, + ) + + +def normalize_path(path: str) -> str: + """Normalize path.""" + stripped = path.strip() + if not stripped: + return "/" + if stripped.startswith("http://") or stripped.startswith("https://"): + return stripped + has_trailing_slash = stripped.endswith("/") + segments = [quote(segment, safe=":@%") for segment in stripped.strip("/").split("/") if segment] + normalized = "/" + "/".join(segments) + if has_trailing_slash and normalized != "/": + normalized += "/" + return normalized + + +def normalize_params(params: Mapping[str, object] | None) -> QueryParams | None: + """Normalize params.""" + if params is None: + return None + normalized: dict[str, QueryParamValue] = {} + for key, value in params.items(): + if value is None: + continue + if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): + normalized[key] = [normalize_query_scalar(item) for item in value] + else: + normalized[key] = normalize_query_scalar(value) + return normalized + + +def normalize_query_scalar(value: object) -> QueryScalar: + """Normalize query scalar.""" + if isinstance(value, str | int | float | bool): + return value + return str(value) + + +def normalize_files(files: Mapping[str, object] | None) -> RequestFiles | None: + """Normalize files.""" + if files is None: + return None + return {key: normalize_file_value(value) for key, value in files.items()} + + +def normalize_file_value(value: object) -> FileValue: + """Normalize file value.""" + if isinstance(value, bytes | str | BytesIO): + return value + if isinstance(value, tuple): + return value + raise TypeError("Неподдерживаемый тип файла для multipart upload.") + + +def merge_headers( + *, + context: RequestContext, + headers: Mapping[str, str] | None, + idempotency_key: str | None, + user_agent: str, + bearer_token: str | None, +) -> dict[str, str]: + """Merge request headers with an already resolved bearer token.""" + + merged: dict[str, str] = { + "Accept": "application/json", + "User-Agent": user_agent, + } + merged.update(dict(context.headers)) + if headers is not None: + merged.update(dict(headers)) + if idempotency_key is not None: + merged["Idempotency-Key"] = idempotency_key + if bearer_token is not None: + merged["Authorization"] = f"Bearer {bearer_token}" + return merged + + +def build_user_agent(user_agent_suffix: str | None) -> str: + """Build user agent.""" + try: + package_version = importlib_metadata.version("avito-py") + except importlib_metadata.PackageNotFoundError: + package_version = "0+unknown" + user_agent = ( + f"avito-py/{package_version} " + f"python/{platform.python_version()} " + f"httpx/{httpx.__version__}" + ) + if user_agent_suffix is not None: + user_agent += f" {user_agent_suffix}" + return user_agent + + +def decide_transport_retry( + *, + retry_policy: RetryPolicy, + method: str, + attempt: int, + context: RequestContext, + is_timeout: bool, + idempotency_key: str | None, +) -> RetryDecision: + """Decide transport retry.""" + if attempt >= retry_policy.max_attempts: + return RetryDecision(False) + if not retry_policy.retry_on_transport_error: + return RetryDecision(False) + if not is_retryable_request( + retry_policy=retry_policy, + method=method, + context=context, + idempotency_key=idempotency_key, + ): + return RetryDecision(False) + return RetryDecision( + True, + reason="timeout" if is_timeout else "transport_error", + delay_seconds=retry_policy.compute_backoff(attempt), + ) + + +def decide_http_retry( + *, + retry_policy: RetryPolicy, + method: str, + attempt: int, + context: RequestContext, + response: httpx.Response, + idempotency_key: str | None, +) -> RetryDecision: + """Decide http retry.""" + if attempt >= retry_policy.max_attempts: + return RetryDecision(False) + if not is_retryable_request( + retry_policy=retry_policy, + method=method, + context=context, + idempotency_key=idempotency_key, + ): + return RetryDecision(False) + if response.status_code == 429: + if not retry_policy.retry_on_rate_limit: + return RetryDecision(False) + delay = get_retry_after_seconds(response.headers) + if response.headers.get("retry-after") is None: + delay = retry_policy.compute_backoff(attempt) + if delay > retry_policy.max_rate_limit_wait_seconds: + return RetryDecision(False) + return RetryDecision(True, reason="rate_limit", delay_seconds=delay) + if 500 <= response.status_code < 600 and retry_policy.retry_on_server_error: + return RetryDecision( + True, + reason="server_error", + delay_seconds=retry_policy.compute_backoff(attempt), + ) + return RetryDecision(False) + + +def is_retryable_request( + *, + retry_policy: RetryPolicy, + method: str, + context: RequestContext, + idempotency_key: str | None, +) -> bool: + """Return whether retryable request.""" + if context.retry_disabled: + return False + normalized_method = method.upper() + if normalized_method in {"POST", "PATCH"} and idempotency_key is None: + return False + if normalized_method == "DELETE" and idempotency_key is None and not context.allow_retry: + return False + return retry_policy.is_retryable_method(normalized_method, explicit_retry=context.allow_retry) + + +def map_http_error( + response: httpx.Response, + *, + operation: str | None = None, + attempt: int | None = None, +) -> Exception: + """Map http error.""" + payload = safe_payload(response) + message = extract_message(payload) or f"HTTP {response.status_code}" + error_code = extract_error_code(payload) + details = extract_error_details(payload) + retry_after = get_retry_after_seconds(response.headers) if response.status_code == 429 else None + request_id = extract_request_id(response.headers) + headers = dict(response.headers) + method = response.request.method + endpoint = response.request.url.path + metadata = {"method": method, "path": endpoint} + error_type: type[AvitoError] + if response.status_code == 401: + error_type = AuthenticationError + elif response.status_code == 403: + error_type = AuthorizationError + elif response.status_code in {400, 422}: + error_type = ValidationError + elif response.status_code == 409: + error_type = ConflictError + elif response.status_code == 429: + error_type = RateLimitError + elif response.status_code in {405, 501}: + error_type = UnsupportedOperationError + else: + error_type = UpstreamApiError + return error_type( + message, + status_code=response.status_code, + error_code=error_code, + operation=operation, + attempt=attempt, + method=method, + endpoint=endpoint, + details=details, + retry_after=retry_after, + request_id=request_id, + metadata=metadata, + payload=payload, + headers=headers, + ) + + +def safe_payload(response: httpx.Response) -> object: + """Return a safe payload.""" + content_type = response.headers.get("content-type", "") + if "application/json" in content_type: + try: + return response.json() + except json.JSONDecodeError: + return response.text + return response.text + + +def extract_message(payload: object) -> str | None: + """Extract message.""" + if isinstance(payload, dict): + for key in ("message", "error_description", "error", "detail"): + value = payload.get(key) + if isinstance(value, str) and value: + return value + if isinstance(payload, str) and payload: + return payload + return None + + +def extract_error_code(payload: object) -> str | None: + """Extract error code.""" + if not isinstance(payload, dict): + return None + value = payload.get("code") or payload.get("error") + return value if isinstance(value, str) else None + + +def extract_error_details(payload: object) -> object | None: + """Extract error details.""" + if not isinstance(payload, Mapping): + return None + for key in ("details", "fields", "errors", "violations"): + value = payload.get(key) + if value is not None: + return cast(object, value) + return None + + +def extract_request_id(headers: Mapping[str, str]) -> str | None: + """Extract request id.""" + for key in ("x-request-id", "x-correlation-id", "x-amzn-requestid"): + value = headers.get(key) + if value: + return value + return None + + +def get_retry_after_seconds(headers: Mapping[str, str]) -> float: + """Return retry after seconds.""" + raw_value = headers.get("retry-after") + if raw_value is None: + return _MIN_RETRY_AFTER_SECONDS + try: + return max(float(raw_value), 0.0) + except ValueError: + try: + retry_at = parsedate_to_datetime(raw_value) + except (TypeError, ValueError): + return _MIN_RETRY_AFTER_SECONDS + if retry_at.tzinfo is None: + retry_at = retry_at.replace(tzinfo=UTC) + return max((retry_at - datetime.now(UTC)).total_seconds(), 0.0) + + +def elapsed_ms(started_at: float) -> int: + """Return elapsed ms.""" + return max(int((time.perf_counter() - started_at) * 1000), 0) + + +def safe_endpoint(endpoint: str) -> str: + """Return a safe endpoint.""" + parsed = urlsplit(endpoint) + if parsed.scheme or parsed.netloc: + return parsed.path or "/" + return endpoint + + +def extract_filename(content_disposition: str | None) -> str | None: + """Extract filename.""" + if content_disposition is None: + return None + message = Message() + message["content-disposition"] = content_disposition + filename = message.get_param("filename", header="content-disposition") + if isinstance(filename, tuple): + _, _, decoded_value = filename + return decoded_value + return filename + + +def _get_header(headers: Mapping[str, str], name: str) -> str | None: + """Return header.""" + value = headers.get(name) + if value is not None: + return value + lowered_name = name.lower() + for key, item in headers.items(): + if key.lower() == lowered_name: + return item + return None diff --git a/avito/core/async_pagination.py b/avito/core/async_pagination.py new file mode 100644 index 0000000..5290566 --- /dev/null +++ b/avito/core/async_pagination.py @@ -0,0 +1,177 @@ +"""Асинхронные абстракции пагинации для типизированных ответов SDK.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable + +from avito.core.types import JsonPage + +type AsyncPageFetcher[ItemT] = Callable[[int | None, str | None], Awaitable[JsonPage[ItemT]]] + + +class AsyncPaginatedList[ItemT]: + """Ленивый async-контейнер страниц без list API.""" + + def __init__( + self, + fetch_page: AsyncPageFetcher[ItemT], + *, + start_page: int = 1, + first_page: JsonPage[ItemT] | None = None, + ) -> None: + """Initialize AsyncPaginatedList.""" + self._fetch_page = fetch_page + self._items: list[ItemT] = [] + self._known_total: int | None = None + self._source_total: int | None = None + self._next_page_number: int | None = start_page + self._next_cursor: str | None = None + self._exhausted = False + self._active_iterator = False + if first_page is not None: + self._consume_page(first_page) + + def __aiter__(self) -> AsyncIterator[ItemT]: + """Run the aiter helper.""" + if self._active_iterator: + raise RuntimeError( + "AsyncPaginatedList уже итерируется; используйте materialize() " + "или создайте отдельный список." + ) + self._active_iterator = True + return self._iterate() + + async def _iterate(self) -> AsyncIterator[ItemT]: + """Iterate iterate.""" + index = 0 + try: + while True: + if index < len(self._items): + yield self._items[index] + index += 1 + continue + if self._exhausted: + return + await self._load_next_page() + finally: + self._active_iterator = False + + async def materialize(self) -> list[ItemT]: + """Явно загружает все страницы и возвращает snapshot-список.""" + + while not self._exhausted: + await self._load_next_page() + return list(self._items) + + async def aload_until(self, index: int) -> None: + """Загружает страницы, пока локально не появится элемент с указанным индексом.""" + + while len(self._items) <= index and not self._exhausted: + await self._load_next_page() + + @property + def loaded_count(self) -> int: + """Количество элементов, уже загруженных локально.""" + + return len(self._items) + + @property + def known_total(self) -> int | None: + """Общее количество элементов, если API вернул достоверный total.""" + + return self._known_total + + @property + def source_total(self) -> int | None: + """Общий total из API без ограничения локальным limit.""" + + return self._source_total + + @property + def is_materialized(self) -> bool: + """Показывает, загружены ли все страницы коллекции.""" + + return self._exhausted + + async def _load_next_page(self) -> None: + """Load next page.""" + if self._exhausted: + return + page = await self._fetch_page(self._next_page_number, self._next_cursor) + self._consume_page(page) + + def _consume_page(self, page: JsonPage[ItemT]) -> None: + """Consume page.""" + self._items.extend(page.items) + self._known_total = page.total + if page.source_total is not None: + self._source_total = page.source_total + if not page.has_next: + self._exhausted = True + self._next_page_number = None + self._next_cursor = None + return + if page.next_cursor is not None: + self._next_cursor = page.next_cursor + self._next_page_number = None + return + if page.page is not None: + self._next_page_number = page.page + 1 + self._next_cursor = None + return + if self._next_page_number is not None: + self._next_page_number += 1 + return + self._exhausted = True + self._next_cursor = None + + +class AsyncPaginator[ItemT]: + """Обходит страницы API асинхронно и собирает типизированный результат.""" + + def __init__(self, fetch_page: AsyncPageFetcher[ItemT]) -> None: + """Initialize AsyncPaginator.""" + self._fetch_page = fetch_page + + async def iter_pages(self, *, start_page: int = 1) -> AsyncIterator[JsonPage[ItemT]]: + """Итерирует страницы, пока API сообщает о продолжении списка.""" + + page_number: int | None = start_page + cursor: str | None = None + while True: + page = await self._fetch_page(page_number, cursor) + yield page + if not page.has_next: + return + if page.next_cursor is not None: + cursor = page.next_cursor + page_number = None + continue + if page_number is None: + return + page_number += 1 + + async def collect(self, *, start_page: int = 1) -> list[ItemT]: + """Собирает элементы всех страниц в один список.""" + + items: list[ItemT] = [] + async for page in self.iter_pages(start_page=start_page): + items.extend(page.items) + return items + + def as_list( + self, + *, + start_page: int = 1, + first_page: JsonPage[ItemT] | None = None, + ) -> AsyncPaginatedList[ItemT]: + """Возвращает ленивый async-контейнер поверх последовательности страниц.""" + + return AsyncPaginatedList( + self._fetch_page, + start_page=start_page, + first_page=first_page, + ) + + +__all__ = ("AsyncPageFetcher", "AsyncPaginatedList", "AsyncPaginator") diff --git a/avito/core/async_transport.py b/avito/core/async_transport.py new file mode 100644 index 0000000..4db5cc1 --- /dev/null +++ b/avito/core/async_transport.py @@ -0,0 +1,333 @@ +"""Асинхронный transport-слой SDK поверх `httpx.AsyncClient`.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import time +from collections.abc import Awaitable, Callable, Mapping +from typing import TYPE_CHECKING + +import httpx + +from avito.core import _transport_shared as shared +from avito.core._async_rate_limit import AsyncRateLimiter +from avito.core.exceptions import ResponseMappingError, TransportError +from avito.core.retries import RetryDecision +from avito.core.types import BinaryResponse, HttpMethod, RequestContext, TransportDebugInfo + +if TYPE_CHECKING: + from avito.auth.async_provider import AsyncAuthProvider + from avito.config import AvitoSettings + +_LOGGER = logging.getLogger("avito.transport") + + +class AsyncTransport: + """Выполняет HTTP-запросы асинхронно, применяет retry и маппит ошибки API.""" + + def __init__( + self, + settings: AvitoSettings, + *, + auth_provider: AsyncAuthProvider | None = None, + client: httpx.AsyncClient | None = None, + sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, + ) -> None: + """Initialize AsyncTransport.""" + self._settings = settings + self._auth_provider = auth_provider + self._retry_policy = settings.retry_policy + self._client = client or httpx.AsyncClient( + base_url=settings.base_url.rstrip("/"), + timeout=shared.build_httpx_timeout(settings.timeouts), + ) + self._sleep = sleep + self._rate_limiter = AsyncRateLimiter(settings.retry_policy, sleep=sleep) + self._user_agent = shared.build_user_agent(settings.user_agent_suffix) + + async def __aenter__(self) -> AsyncTransport: + """Enter the async context manager.""" + return self + + async def __aexit__(self, *exc: object) -> None: + """Exit the async context manager.""" + await self.aclose() + + @property + def auth_provider(self) -> AsyncAuthProvider | None: + """Возвращает auth provider transport-слоя, если он настроен.""" + + return self._auth_provider + + def debug_info(self) -> TransportDebugInfo: + """Возвращает безопасный снимок transport-конфигурации без секретов.""" + + return TransportDebugInfo( + base_url=str(self._client.base_url), + user_id=self._settings.user_id, + requires_auth=self._auth_provider is not None, + timeout_connect=self._settings.timeouts.connect, + timeout_read=self._settings.timeouts.read, + timeout_write=self._settings.timeouts.write, + timeout_pool=self._settings.timeouts.pool, + retry_max_attempts=self._retry_policy.max_attempts, + retryable_methods=self._retry_policy.retryable_methods, + ) + + async def aclose(self) -> None: + """Закрывает внутренний экземпляр `httpx.AsyncClient`.""" + + await self._client.aclose() + + async def request( + self, + method: HttpMethod, + path: str, + *, + context: RequestContext, + params: Mapping[str, object] | None = None, + json_body: object | None = None, + data: Mapping[str, object] | None = None, + files: Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, + content: bytes | None = None, + idempotency_key: str | None = None, + ) -> httpx.Response: + """Выполняет запрос и возвращает успешный `httpx.Response`.""" + + normalized_path = shared.normalize_path(path) + bearer_token = ( + await self._auth_provider.get_access_token() + if context.requires_auth and self._auth_provider is not None + else None + ) + request_headers = shared.merge_headers( + context=context, + headers=headers, + idempotency_key=idempotency_key, + user_agent=self._user_agent, + bearer_token=bearer_token, + ) + timeout = shared.build_httpx_timeout(context.timeout or self._settings.timeouts) + attempt = 0 + unauthorized_refresh_used = False + + while True: + attempt += 1 + limiter_delay = await self._rate_limiter.acquire() + if limiter_delay > 0.0: + _LOGGER.info( + "transport rate limit delay", + extra={ + "operation": context.operation_name, + "endpoint": shared.safe_endpoint(normalized_path), + "method": method, + "attempt": attempt, + "delay_ms": int(limiter_delay * 1000), + "reason": "client_rate_limit", + }, + ) + try: + started_at = time.perf_counter() + response = await self._client.request( + method=method, + url=normalized_path, + params=shared.normalize_params(params), + json=json_body, + data=data, + files=shared.normalize_files(files), + headers=request_headers, + content=content, + timeout=timeout, + ) + self._log_http_exchange( + operation=context.operation_name, + endpoint=normalized_path, + method=method, + attempt=attempt, + status=response.status_code, + latency_ms=shared.elapsed_ms(started_at), + request_id=shared.extract_request_id(response.headers), + ) + self._rate_limiter.observe_response(headers=response.headers) + except (httpx.TimeoutException, httpx.NetworkError) as exc: + self._log_http_exchange( + operation=context.operation_name, + endpoint=normalized_path, + method=method, + attempt=attempt, + status=None, + latency_ms=shared.elapsed_ms(started_at), + request_id=None, + ) + decision = shared.decide_transport_retry( + retry_policy=self._retry_policy, + method=method, + attempt=attempt, + context=context, + is_timeout=isinstance(exc, httpx.TimeoutException), + idempotency_key=idempotency_key, + ) + if decision.should_retry: + self._log_retry( + operation=context.operation_name, + endpoint=normalized_path, + method=method, + attempt=attempt, + status=None, + decision=decision, + ) + await self._sleep(decision.delay_seconds) + continue + raise TransportError( + str(exc), + operation=context.operation_name, + attempt=attempt, + method=method, + endpoint=shared.safe_endpoint(normalized_path), + metadata={"timeout": isinstance(exc, httpx.TimeoutException)}, + ) from exc + + if response.status_code == 401 and context.requires_auth and self._auth_provider is not None: + if unauthorized_refresh_used: + raise shared.map_http_error( + response, + operation=context.operation_name, + attempt=attempt, + ) + unauthorized_refresh_used = True + self._auth_provider.invalidate_token() + refreshed_headers = dict(request_headers) + refreshed_headers["Authorization"] = ( + f"Bearer {await self._auth_provider.get_access_token()}" + ) + request_headers = refreshed_headers + continue + + if response.status_code == 429 or 500 <= response.status_code < 600: + decision = shared.decide_http_retry( + retry_policy=self._retry_policy, + method=method, + attempt=attempt, + context=context, + response=response, + idempotency_key=idempotency_key, + ) + if decision.should_retry: + self._log_retry( + operation=context.operation_name, + endpoint=normalized_path, + method=method, + attempt=attempt, + status=response.status_code, + decision=decision, + ) + await self._sleep(decision.delay_seconds) + continue + raise shared.map_http_error( + response, + operation=context.operation_name, + attempt=attempt, + ) + + if response.is_error: + raise shared.map_http_error( + response, + operation=context.operation_name, + attempt=attempt, + ) + return response + + async def request_json( + self, + method: HttpMethod, + path: str, + *, + context: RequestContext, + params: Mapping[str, object] | None = None, + json_body: object | None = None, + data: Mapping[str, object] | None = None, + files: Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, + idempotency_key: str | None = None, + ) -> object: + """Выполняет запрос и возвращает JSON-ответ.""" + + response = await self.request( + method, + path, + context=context, + params=params, + json_body=json_body, + data=data, + files=files, + headers=headers, + idempotency_key=idempotency_key, + ) + try: + return response.json() + except json.JSONDecodeError as exc: + raise ResponseMappingError( + "Ответ API не является корректным JSON.", + status_code=response.status_code, + operation=context.operation_name, + metadata={"content_type": response.headers.get("content-type")}, + payload=response.text, + headers=dict(response.headers), + ) from exc + + async def download_binary( + self, + path: str, + *, + context: RequestContext, + params: Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, + ) -> BinaryResponse: + """Выполняет запрос и возвращает полный бинарный ответ.""" + + response = await self.request("GET", path, context=context, params=params, headers=headers) + content = await response.aread() + return BinaryResponse( + content=content, + content_type=response.headers.get("content-type"), + filename=shared.extract_filename(response.headers.get("content-disposition")), + status_code=response.status_code, + headers=dict(response.headers), + ) + + def _log_retry( + self, + *, + operation: str, + endpoint: str, + method: str, + attempt: int, + status: int | None, + decision: RetryDecision, + ) -> None: + """Log retry.""" + _LOGGER.info( + "transport retry", + extra={ + "operation": operation, + "endpoint": shared.safe_endpoint(endpoint), + "method": method, + "attempt": attempt, + "status": status, + "delay_ms": int(decision.delay_seconds * 1000), + "reason": decision.reason, + }, + ) + + def _log_http_exchange(self, **extra: object) -> None: + """Log http exchange.""" + _LOGGER.debug( + "transport http exchange", + extra={**extra, "endpoint": shared.safe_endpoint(str(extra["endpoint"]))}, + ) + + +__all__ = ("AsyncTransport",) diff --git a/avito/core/deprecation.py b/avito/core/deprecation.py index b6099c7..4402be8 100644 --- a/avito/core/deprecation.py +++ b/avito/core/deprecation.py @@ -1,10 +1,11 @@ from __future__ import annotations import warnings -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass from functools import wraps -from typing import ParamSpec, TypeVar +from inspect import iscoroutinefunction +from typing import ParamSpec, TypeVar, cast P = ParamSpec("P") R = TypeVar("R") @@ -29,6 +30,7 @@ def warn_deprecated_once( removal_version: str, deprecated_since: str, ) -> None: + """Run the warn deprecated once helper.""" if symbol in _WARNED_SYMBOLS: return _WARNED_SYMBOLS.add(symbol) @@ -50,6 +52,7 @@ def deprecated_method( removal_version: str, deprecated_since: str, ) -> Callable[[Callable[P, R]], Callable[P, R]]: + """Run the deprecated method helper.""" metadata = DeprecatedSdkSymbol( symbol=symbol, replacement=replacement, @@ -58,8 +61,26 @@ def deprecated_method( ) def decorate(method: Callable[P, R]) -> Callable[P, R]: + """Run the decorate helper.""" + if iscoroutinefunction(method): + @wraps(method) + async def async_wrapped(*args: P.args, **kwargs: P.kwargs) -> R: + """Run the async wrapped helper.""" + warn_deprecated_once( + symbol=symbol, + replacement=replacement, + removal_version=removal_version, + deprecated_since=deprecated_since, + ) + async_method = cast(Callable[P, Awaitable[R]], method) + return await async_method(*args, **kwargs) + + async_wrapped.__sdk_deprecation__ = metadata # type: ignore[attr-defined] + return async_wrapped # type: ignore[return-value] + @wraps(method) def wrapped(*args: P.args, **kwargs: P.kwargs) -> R: + """Run the wrapped helper.""" warn_deprecated_once( symbol=symbol, replacement=replacement, diff --git a/avito/core/domain.py b/avito/core/domain.py index f2712a7..9c9c5d6 100644 --- a/avito/core/domain.py +++ b/avito/core/domain.py @@ -7,10 +7,11 @@ from typing import TYPE_CHECKING, TypeVar from avito.core.exceptions import ValidationError -from avito.core.operations import OperationExecutor, OperationSpec +from avito.core.operations import AsyncOperationExecutor, OperationExecutor, OperationSpec from avito.core.types import ApiTimeouts, RequestContext, RetryOverride if TYPE_CHECKING: + from avito.core.async_transport import AsyncTransport from avito.core.transport import Transport ResponseT = TypeVar("ResponseT") @@ -75,7 +76,65 @@ def _resolve_user_id(self, user_id: int | str | None = None) -> int: return resolved_user_id +@dataclass(slots=True, frozen=True) +class AsyncDomainObject: + """Базовый async-доменный объект с доступом к transport-слою.""" + + transport: AsyncTransport + + async def _execute( + self, + spec: OperationSpec[ResponseT], + *, + path_params: Mapping[str, object] | None = None, + query: object | Mapping[str, object] | None = None, + request: object | Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, + idempotency_key: str | None = None, + data: Mapping[str, object] | None = None, + files: Mapping[str, object] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ResponseT: + """Выполняет v2 operation spec через общий async executor.""" + + return await AsyncOperationExecutor(self.transport).execute( + spec, + path_params=path_params, + query=query, + request=request, + headers=headers, + idempotency_key=idempotency_key, + data=data, + files=files, + timeout=timeout, + retry=retry, + ) + + async def _resolve_user_id(self, user_id: int | str | None = None) -> int: + """Возвращает user_id из аргумента, настроек SDK или профиля текущего пользователя.""" + + if user_id is not None: + return int(user_id) + configured_user_id = self.transport.debug_info().user_id + if configured_user_id is not None: + return configured_user_id + payload = await self.transport.request_json( + "GET", + "/core/v1/accounts/self", + context=RequestContext("accounts.resolve_user_id"), + ) + resolved_user_id = _extract_user_id(payload) + if resolved_user_id is None: + raise ValidationError( + "Для операции требуется `user_id`: передайте его в фабрику клиента, " + "в метод операции или задайте `AVITO_USER_ID`." + ) + return resolved_user_id + + def _extract_user_id(payload: object) -> int | None: + """Extract user id.""" if not isinstance(payload, dict): return None for key in ("id", "user_id", "userId"): @@ -87,4 +146,4 @@ def _extract_user_id(payload: object) -> int | None: return None -__all__ = ("DomainObject",) +__all__ = ("AsyncDomainObject", "DomainObject") diff --git a/avito/core/operations.py b/avito/core/operations.py index 30d9287..1ea1db1 100644 --- a/avito/core/operations.py +++ b/avito/core/operations.py @@ -90,6 +90,41 @@ def request_json( """Execute request and return decoded JSON payload.""" +class AsyncOperationTransport(Protocol): + """Async transport methods required by the operation executor.""" + + async def request( + self, + method: HttpMethod, + path: str, + *, + context: RequestContext, + params: Mapping[str, object] | None = None, + json_body: object | None = None, + data: Mapping[str, object] | None = None, + files: Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, + content: bytes | None = None, + idempotency_key: str | None = None, + ) -> httpx.Response: + """Execute raw request and return response object.""" + + async def request_json( + self, + method: HttpMethod, + path: str, + *, + context: RequestContext, + params: Mapping[str, object] | None = None, + json_body: object | None = None, + data: Mapping[str, object] | None = None, + files: Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, + idempotency_key: str | None = None, + ) -> object: + """Execute request and return decoded JSON payload.""" + + @dataclass(slots=True, frozen=True) class EmptyResponse: """Typed result for successful operations without response body.""" @@ -121,6 +156,7 @@ class OperationExecutor: """Execute operation specs through the shared transport layer.""" def __init__(self, transport: OperationTransport) -> None: + """Initialize OperationExecutor.""" self._transport = transport def execute( @@ -201,6 +237,88 @@ def execute( return spec.response_model.from_payload(payload) +class AsyncOperationExecutor: + """Execute operation specs through the async transport layer.""" + + def __init__(self, transport: AsyncOperationTransport) -> None: + """Initialize AsyncOperationExecutor.""" + self._transport = transport + + async def execute( + self, + spec: OperationSpec[ResponseT], + *, + path_params: Mapping[str, object] | None = None, + query: object | Mapping[str, object] | None = None, + request: object | Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, + idempotency_key: str | None = None, + data: Mapping[str, object] | None = None, + files: Mapping[str, object] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ResponseT: + """Execute operation spec and return typed response object.""" + + path = render_path(spec.path, path_params or {}) + params = _serialize_query(spec, query) + json_body = _serialize_request(spec, request) + request_headers = _merge_content_type(headers, spec.content_type) + effective_retry = spec.retry_mode if retry is None or retry == "default" else retry + context = RequestContext( + operation_name=spec.name, + allow_retry=effective_retry == "enabled", + retry_disabled=effective_retry == "disabled", + requires_auth=spec.requires_auth, + timeout=timeout, + ) + + if spec.response_kind == "binary": + return cast( + ResponseT, + await _request_binary_async( + self._transport, + spec=spec, + path=path, + context=context, + params=params, + headers=request_headers, + idempotency_key=idempotency_key, + ), + ) + if spec.response_kind == "empty": + response = await self._transport.request( + spec.method, + path, + context=context, + params=params, + json_body=json_body, + data=data, + files=files, + headers=request_headers, + idempotency_key=idempotency_key, + ) + return cast( + ResponseT, + EmptyResponse(status_code=response.status_code, headers=dict(response.headers)), + ) + + payload = await self._transport.request_json( + spec.method, + path, + context=context, + params=params, + json_body=json_body, + data=data, + files=files, + headers=request_headers, + idempotency_key=idempotency_key, + ) + if spec.response_model is None: + return cast(ResponseT, payload) + return spec.response_model.from_payload(payload) + + def render_path(path_template: str, path_params: Mapping[str, object]) -> str: """Render operation path and percent-encode path parameter values.""" @@ -214,6 +332,7 @@ def _serialize_query[SpecResponseT]( spec: OperationSpec[SpecResponseT], query: object | Mapping[str, object] | None, ) -> Mapping[str, object] | None: + """Serialize query.""" if query is None: return None if isinstance(query, RequestModel): @@ -229,6 +348,7 @@ def _serialize_request[SpecResponseT]( spec: OperationSpec[SpecResponseT], request: object | Mapping[str, object] | None, ) -> object | None: + """Serialize request.""" if request is None: return None if isinstance(request, RequestModel): @@ -244,6 +364,7 @@ def _merge_content_type( headers: Mapping[str, str] | None, content_type: str | None, ) -> Mapping[str, str] | None: + """Run the merge content type helper.""" if content_type is None: return headers merged = dict(headers or {}) @@ -261,6 +382,7 @@ def _request_binary[SpecResponseT]( headers: Mapping[str, str] | None, idempotency_key: str | None, ) -> BinaryResponse: + """Run the request binary helper.""" response = transport.request( spec.method, path, @@ -278,7 +400,36 @@ def _request_binary[SpecResponseT]( ) +async def _request_binary_async[SpecResponseT]( + transport: AsyncOperationTransport, + *, + spec: OperationSpec[SpecResponseT], + path: str, + context: RequestContext, + params: Mapping[str, object] | None, + headers: Mapping[str, str] | None, + idempotency_key: str | None, +) -> BinaryResponse: + """Run the request binary async helper.""" + response = await transport.request( + spec.method, + path, + context=context, + params=params, + headers=headers, + idempotency_key=idempotency_key, + ) + return BinaryResponse( + content=response.content, + content_type=response.headers.get("content-type"), + filename=_extract_filename(response.headers.get("content-disposition")), + status_code=response.status_code, + headers=dict(response.headers), + ) + + def _extract_filename(content_disposition: str | None) -> str | None: + """Extract filename.""" if content_disposition is None: return None message = Message() @@ -292,6 +443,8 @@ def _extract_filename(content_disposition: str | None) -> str | None: __all__ = ( "EmptyResponse", + "AsyncOperationExecutor", + "AsyncOperationTransport", "OperationExecutor", "OperationSpec", "OperationTransport", diff --git a/avito/core/rate_limit.py b/avito/core/rate_limit.py index 54b9a22..93705cb 100644 --- a/avito/core/rate_limit.py +++ b/avito/core/rate_limit.py @@ -6,6 +6,7 @@ import time from collections.abc import Callable, Mapping +from avito.core._transport_shared import RateLimitState from avito.core.retries import RetryPolicy @@ -19,12 +20,8 @@ def __init__( clock: Callable[[], float] = time.monotonic, sleep: Callable[[float], None] = time.sleep, ) -> None: - self._enabled = policy.rate_limit_enabled - self._rate = max(policy.rate_limit_requests_per_second, 0.0) - self._capacity = max(policy.rate_limit_burst, 0) - self._tokens = float(self._capacity) - self._updated_at = clock() - self._blocked_until = 0.0 + """Initialize RateLimiter.""" + self._state = RateLimitState.from_policy(policy, now=clock()) self._clock = clock self._sleep = sleep self._lock = threading.Lock() @@ -32,9 +29,6 @@ def __init__( def acquire(self) -> float: """Ждёт, пока запрос можно безопасно отправить, и возвращает задержку.""" - if not self._enabled or self._rate <= 0.0 or self._capacity <= 0: - return 0.0 - total_delay = 0.0 while True: delay = self._reserve_or_delay() @@ -46,54 +40,13 @@ def acquire(self) -> float: def observe_response(self, *, headers: Mapping[str, str]) -> None: """Обновляет локальный cooldown по rate-limit headers upstream API.""" - if not self._enabled or self._rate <= 0.0: - return - - remaining = _get_header(headers, "x-ratelimit-remaining") - if remaining is None: - return - try: - remaining_count = int(remaining) - except ValueError: - return - if remaining_count <= 0: - self._block_for(1.0 / self._rate) - - def _reserve_or_delay(self) -> float: with self._lock: - now = self._clock() - self._refill(now) - blocked_delay = max(self._blocked_until - now, 0.0) - if blocked_delay > 0.0: - return blocked_delay - if self._tokens >= 1.0: - self._tokens -= 1.0 - return 0.0 - return (1.0 - self._tokens) / self._rate + self._state.observe_response(now=self._clock(), headers=headers) - def _refill(self, now: float) -> None: - elapsed = max(now - self._updated_at, 0.0) - if elapsed > 0.0: - self._tokens = min(float(self._capacity), self._tokens + elapsed * self._rate) - self._updated_at = now - - def _block_for(self, delay: float) -> None: - if delay <= 0.0: - return + def _reserve_or_delay(self) -> float: + """Run the reserve or delay helper.""" with self._lock: - self._blocked_until = max(self._blocked_until, self._clock() + delay) - self._tokens = min(self._tokens, 0.0) - - -def _get_header(headers: Mapping[str, str], name: str) -> str | None: - value = headers.get(name) - if value is not None: - return value - lowered_name = name.lower() - for key, item in headers.items(): - if key.lower() == lowered_name: - return item - return None + return self._state.compute_delay(self._clock()) __all__ = ("RateLimiter",) diff --git a/avito/core/swagger.py b/avito/core/swagger.py index 5bd22ea..25e5a94 100644 --- a/avito/core/swagger.py +++ b/avito/core/swagger.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass, field from types import MappingProxyType -from typing import ParamSpec, TypeVar +from typing import Literal, ParamSpec, TypeVar from avito.core.exceptions import ConfigurationError @@ -18,12 +18,14 @@ def _freeze_mapping(value: Mapping[str, str] | None) -> Mapping[str, str]: + """Run the freeze mapping helper.""" if value is None: return _EMPTY_MAPPING return MappingProxyType(dict(value)) def _normalize_method(method: str) -> str: + """Normalize method.""" normalized = method.strip().upper() if not normalized: raise ConfigurationError("HTTP-метод Swagger binding не может быть пустым.") @@ -31,6 +33,7 @@ def _normalize_method(method: str) -> str: def _normalize_path(path: str) -> str: + """Normalize path.""" normalized = path.strip() if not normalized.startswith("/"): raise ConfigurationError("Swagger path должен начинаться с `/`.") @@ -55,8 +58,12 @@ class SwaggerOperationBinding: method_args: Mapping[str, str] = field(default_factory=lambda: _EMPTY_MAPPING) deprecated: bool = False legacy: bool = False + variant: Literal["sync", "async"] = "sync" def __post_init__(self) -> None: + """Run the post init helper.""" + if self.variant not in {"sync", "async"}: + raise ConfigurationError("Swagger binding variant должен быть `sync` или `async`.") object.__setattr__(self, "method", _normalize_method(self.method)) object.__setattr__(self, "path", _normalize_path(self.path)) object.__setattr__(self, "factory_args", _freeze_mapping(self.factory_args)) @@ -74,6 +81,7 @@ def swagger_operation( method_args: Mapping[str, str] | None = None, deprecated: bool = False, legacy: bool = False, + variant: Literal["sync", "async"] = "sync", ) -> Callable[[Callable[P, R]], Callable[P, R]]: """Записывает Swagger binding metadata на публичный SDK-метод.""" @@ -87,9 +95,11 @@ def swagger_operation( method_args=_freeze_mapping(method_args), deprecated=deprecated, legacy=legacy, + variant=variant, ) def decorate(func: Callable[P, R]) -> Callable[P, R]: + """Run the decorate helper.""" if hasattr(func, "__swagger_binding__") or hasattr(func, "__swagger_bindings__"): raise ConfigurationError("Несколько Swagger binding-ов на одном SDK method запрещены.") func.__swagger_binding__ = binding # type: ignore[attr-defined] diff --git a/avito/core/swagger_discovery.py b/avito/core/swagger_discovery.py index 5c22608..0cf71b3 100644 --- a/avito/core/swagger_discovery.py +++ b/avito/core/swagger_discovery.py @@ -9,9 +9,9 @@ from collections.abc import Mapping from dataclasses import dataclass, field from types import MappingProxyType, ModuleType -from typing import cast +from typing import Literal, cast -from avito.core.domain import DomainObject +from avito.core.domain import AsyncDomainObject, DomainObject from avito.core.swagger import SwaggerOperationBinding from avito.core.swagger_registry import ( SwaggerOperation, @@ -22,7 +22,7 @@ _EMPTY_MAPPING: Mapping[str, str] = MappingProxyType({}) _IGNORED_PACKAGES = frozenset({"auth", "core", "summary", "testing"}) -_NON_DOMAIN_BINDING_MODULES = ("avito.auth.provider",) +_NON_DOMAIN_BINDING_MODULES = ("avito.auth.provider", "avito.auth.async_token_client") @dataclass(frozen=True, slots=True) @@ -43,9 +43,11 @@ class DiscoveredSwaggerBinding: method_args: Mapping[str, str] = field(default_factory=lambda: _EMPTY_MAPPING) deprecated: bool = False legacy: bool = False + variant: Literal["sync", "async"] = "sync" @property def sdk_method(self) -> str: + """Run the sdk method helper.""" return f"{self.module}.{self.class_name}.{self.method_name}" @@ -58,13 +60,43 @@ class SwaggerBindingDiscovery: @property def canonical_map(self) -> Mapping[str, DiscoveredSwaggerBinding]: + """Run the canonical map helper.""" mapped = { binding.operation_key: binding for binding in self.bindings - if binding.operation_key is not None + if binding.operation_key is not None and binding.variant == "sync" } return MappingProxyType(mapped) + @property + def canonical_map_by_variant( + self, + ) -> Mapping[Literal["sync", "async"], Mapping[str, DiscoveredSwaggerBinding]]: + """Run the canonical map by variant helper.""" + mapped: dict[Literal["sync", "async"], dict[str, DiscoveredSwaggerBinding]] = { + "sync": {}, + "async": {}, + } + for binding in self.bindings: + if binding.operation_key is not None: + mapped[binding.variant][binding.operation_key] = binding + return MappingProxyType( + { + "sync": MappingProxyType(mapped["sync"]), + "async": MappingProxyType(mapped["async"]), + } + ) + + def binding_for( + self, + operation_key: str, + *, + variant: Literal["sync", "async"] = "sync", + ) -> DiscoveredSwaggerBinding | None: + """Return discovered binding by operation key and surface variant.""" + + return self.canonical_map_by_variant[variant].get(operation_key) + def discover_swagger_bindings( *, @@ -91,6 +123,7 @@ def discover_swagger_bindings( def _iter_domain_modules(package: ModuleType, package_name: str) -> tuple[ModuleType, ...]: + """Run the iter domain modules helper.""" package_paths = getattr(package, "__path__", None) if package_paths is None: return () @@ -99,10 +132,11 @@ def _iter_domain_modules(package: ModuleType, package_name: str) -> tuple[Module for module_info in pkgutil.iter_modules(package_paths): if not module_info.ispkg or module_info.name in _IGNORED_PACKAGES: continue - module_name = f"{package_name}.{module_info.name}.domain" - if importlib.util.find_spec(module_name) is None: - continue - modules.append(importlib.import_module(module_name)) + for suffix in ("domain", "async_domain"): + module_name = f"{package_name}.{module_info.name}.{suffix}" + if importlib.util.find_spec(module_name) is None: + continue + modules.append(importlib.import_module(module_name)) return tuple(modules) @@ -110,6 +144,7 @@ def _discover_module_bindings( module: ModuleType, registry: SwaggerRegistry | None, ) -> tuple[tuple[DiscoveredSwaggerBinding, ...], tuple[str, ...]]: + """Run the discover module bindings helper.""" bindings: list[DiscoveredSwaggerBinding] = [] legacy_binding_methods: list[str] = [] for _, cls in inspect.getmembers(module, inspect.isclass): @@ -140,12 +175,16 @@ def _discover_module_bindings( def _is_discoverable_binding_class(cls: type[object]) -> bool: + """Return whether discoverable binding class.""" if issubclass(cls, DomainObject) and cls is not DomainObject: return True + if issubclass(cls, AsyncDomainObject) and cls is not AsyncDomainObject: + return True return _optional_string(getattr(cls, "__swagger_domain__", None)) is not None def _method_binding(func: object) -> SwaggerOperationBinding | None: + """Run the method binding helper.""" raw_binding = getattr(func, "__swagger_binding__", None) if isinstance(raw_binding, SwaggerOperationBinding): return raw_binding @@ -160,6 +199,7 @@ def _build_effective_binding( binding: SwaggerOperationBinding, registry: SwaggerRegistry | None, ) -> DiscoveredSwaggerBinding: + """Build effective binding.""" method = normalize_swagger_method(binding.method) path = normalize_swagger_path(binding.path) spec = binding.spec or _optional_string(getattr(cls, "__swagger_spec__", None)) @@ -185,6 +225,7 @@ def _build_effective_binding( method_args=binding.method_args, deprecated=binding.deprecated, legacy=binding.legacy, + variant=binding.variant, ) @@ -192,6 +233,7 @@ def _operation_by_key( operations: tuple[SwaggerOperation, ...], operation_key: str, ) -> SwaggerOperation | None: + """Run the operation by key helper.""" for operation in operations: if operation.key == operation_key: return operation @@ -202,6 +244,7 @@ def _filter_factory_args_for_operation( factory_args: Mapping[str, str], operation: SwaggerOperation | None, ) -> Mapping[str, str]: + """Run the filter factory args for operation helper.""" if operation is None or not factory_args: return factory_args parameter_names = { @@ -224,6 +267,7 @@ def _resolve_spec( method: str, path: str, ) -> str | None: + """Resolve spec.""" matches = [ operation.spec for operation in operations @@ -233,10 +277,12 @@ def _resolve_spec( def _optional_string(value: object) -> str | None: + """Run the optional string helper.""" return value if isinstance(value, str) and value else None def _optional_mapping(value: object) -> Mapping[str, str]: + """Run the optional mapping helper.""" if value is None: return _EMPTY_MAPPING if not isinstance(value, Mapping): diff --git a/avito/core/swagger_linter.py b/avito/core/swagger_linter.py index 5c42839..a31365e 100644 --- a/avito/core/swagger_linter.py +++ b/avito/core/swagger_linter.py @@ -106,6 +106,7 @@ def _validate_operation_spec_coverage( registry: SwaggerRegistry, bindings: Sequence[DiscoveredSwaggerBinding], ) -> tuple[SwaggerReportError, ...]: + """Validate operation spec coverage.""" operations_by_key = {operation.key: operation for operation in registry.operations} used_specs: set[int] = set() errors: list[SwaggerReportError] = [] @@ -144,6 +145,7 @@ def _validate_operation_spec_matches_binding( operation: SwaggerOperation, spec: OperationSpec[object], ) -> tuple[SwaggerReportError, ...]: + """Validate operation spec matches binding.""" errors: list[SwaggerReportError] = [] if normalize_swagger_method(spec.method) != operation.method: errors.append( @@ -176,6 +178,7 @@ def _validate_json_body_model_coverage( registry: SwaggerRegistry, bindings: Sequence[DiscoveredSwaggerBinding], ) -> tuple[SwaggerReportError, ...]: + """Validate json body model coverage.""" operations_by_key = {operation.key: operation for operation in registry.operations} errors: list[SwaggerReportError] = [] @@ -205,6 +208,7 @@ def _validate_operation_json_body_models( operation: SwaggerOperation, spec: OperationSpec[object], ) -> tuple[SwaggerReportError, ...]: + """Validate operation json body models.""" errors: list[SwaggerReportError] = [] request_body = operation.request_body if request_body is not None and _has_json_content(request_body.content_types): @@ -275,6 +279,7 @@ def _validate_operation_json_body_models( def _has_json_content(content_types: Sequence[str]) -> bool: + """Return whether json content.""" return any("application/json" in content_type for content_type in content_types) @@ -284,6 +289,7 @@ def _contract_error( code: str, message: str, ) -> SwaggerReportError: + """Run the contract error helper.""" return SwaggerReportError( code=code, message=message, @@ -293,6 +299,7 @@ def _contract_error( def _validate_no_unbound_operation_specs(used_specs: set[int]) -> tuple[SwaggerReportError, ...]: + """Validate no unbound operation specs.""" errors: list[SwaggerReportError] = [] for module_name, spec_name, spec in _iter_api_domain_operation_specs(): if id(spec) in used_specs: @@ -314,6 +321,7 @@ def _validate_no_unbound_operation_specs(used_specs: set[int]) -> tuple[SwaggerR def _validate_legacy_stacked_binding_metadata( discovery: SwaggerBindingDiscovery, ) -> tuple[SwaggerReportError, ...]: + """Validate legacy stacked binding metadata.""" return tuple( SwaggerReportError( code="SWAGGER_BINDING_METHOD_MULTIPLE", @@ -328,6 +336,7 @@ def _validate_legacy_stacked_binding_metadata( def _validate_single_binding_per_sdk_method( bindings: Sequence[DiscoveredSwaggerBinding], ) -> tuple[SwaggerReportError, ...]: + """Validate single binding per sdk method.""" grouped: defaultdict[str, list[DiscoveredSwaggerBinding]] = defaultdict(list) for binding in bindings: grouped[binding.sdk_method].append(binding) @@ -358,8 +367,11 @@ def _validate_complete_bindings( operations: Sequence[SwaggerOperation], bindings: Sequence[DiscoveredSwaggerBinding], ) -> tuple[SwaggerReportError, ...]: + """Validate complete bindings.""" bound_operation_keys = { - binding.operation_key for binding in bindings if binding.operation_key is not None + binding.operation_key + for binding in bindings + if binding.operation_key is not None and binding.variant == "sync" } errors: list[SwaggerReportError] = [] for operation in operations: @@ -379,13 +391,14 @@ def _validate_complete_bindings( def _validate_duplicate_bindings( bindings: Sequence[DiscoveredSwaggerBinding], ) -> tuple[SwaggerReportError, ...]: - grouped: defaultdict[str, list[DiscoveredSwaggerBinding]] = defaultdict(list) + """Validate duplicate bindings.""" + grouped: defaultdict[tuple[str, str], list[DiscoveredSwaggerBinding]] = defaultdict(list) for binding in bindings: if binding.operation_key is not None: - grouped[binding.operation_key].append(binding) + grouped[(binding.operation_key, binding.variant)].append(binding) errors: list[SwaggerReportError] = [] - for operation_key, operation_bindings in sorted(grouped.items()): + for (operation_key, variant), operation_bindings in sorted(grouped.items()): if len(operation_bindings) < 2: continue methods = ", ".join(binding.sdk_method for binding in operation_bindings) @@ -395,7 +408,7 @@ def _validate_duplicate_bindings( code="SWAGGER_BINDING_DUPLICATE", message=( f"{operation_key}: несколько SDK binding-ов указывают на одну " - f"Swagger operation: {methods}." + f"Swagger operation для variant={variant}: {methods}." ), operation_key=operation_key, sdk_method=binding.sdk_method, @@ -411,6 +424,7 @@ def _resolve_bound_operation( spec_names: set[str], errors: list[SwaggerReportError], ) -> SwaggerOperation | None: + """Resolve bound operation.""" if binding.operation_key is None: errors.append( SwaggerReportError( @@ -456,6 +470,7 @@ def _validate_operation_metadata( operation: SwaggerOperation, sdk_method: Callable[..., object] | None, ) -> tuple[SwaggerReportError, ...]: + """Validate operation metadata.""" errors: list[SwaggerReportError] = [] if binding.operation_id is not None and binding.operation_id != operation.operation_id: errors.append( @@ -521,6 +536,7 @@ def _validate_operation_metadata( def _validate_factory(binding: DiscoveredSwaggerBinding) -> tuple[SwaggerReportError, ...]: + """Validate factory.""" if binding.domain == "auth" and binding.factory is None: return () if binding.factory is None: @@ -533,12 +549,19 @@ def _validate_factory(binding: DiscoveredSwaggerBinding) -> tuple[SwaggerReportE ), ) - factory = getattr(AvitoClient, binding.factory, None) + client_type: type[object] + if binding.variant == "async": + from avito.async_client import AsyncAvitoClient + + client_type = AsyncAvitoClient + else: + client_type = AvitoClient + factory = getattr(client_type, binding.factory, None) if not callable(factory): return ( SwaggerReportError( code="SWAGGER_BINDING_FACTORY_NOT_FOUND", - message=f"{binding.sdk_method}: AvitoClient factory не найден: {binding.factory}.", + message=f"{binding.sdk_method}: client factory не найден: {binding.factory}.", operation_key=binding.operation_key, sdk_method=binding.sdk_method, ), @@ -557,6 +580,7 @@ def _validate_sdk_method_signature( binding: DiscoveredSwaggerBinding, sdk_method: Callable[..., object] | None, ) -> tuple[SwaggerReportError, ...]: + """Validate sdk method signature.""" if sdk_method is None: return ( SwaggerReportError( @@ -578,6 +602,7 @@ def _validate_sdk_method_signature( def _operation_specs_for_sdk_method( sdk_method: Callable[..., object] | None, ) -> tuple[OperationSpec[object], ...]: + """Run the operation specs for sdk method helper.""" if sdk_method is None: return () unwrapped_method = inspect.unwrap(sdk_method) @@ -602,6 +627,7 @@ def _operation_specs_for_sdk_method( def _iter_api_domain_operation_specs() -> tuple[tuple[str, str, OperationSpec[object]], ...]: + """Run the iter api domain operation specs helper.""" specs: list[tuple[str, str, OperationSpec[object]]] = [] for domain in sorted(_API_DOMAINS): for module in _iter_domain_operation_modules(domain): @@ -612,6 +638,7 @@ def _iter_api_domain_operation_specs() -> tuple[tuple[str, str, OperationSpec[ob def _iter_domain_operation_modules(domain: str) -> tuple[ModuleType, ...]: + """Run the iter domain operation modules helper.""" root_module_name = f"avito.{domain}.operations" module = importlib.import_module(root_module_name) modules: list[ModuleType] = [module] @@ -624,17 +651,20 @@ def _iter_domain_operation_modules(domain: str) -> tuple[ModuleType, ...]: def _is_execute_call(node: ast.Call) -> bool: + """Return whether execute call.""" name = _call_name(node.func) return name in {"self._execute", "_execute"} or name.endswith("._execute") def _name(node: ast.AST) -> str | None: + """Run the name helper.""" if isinstance(node, ast.Name): return node.id return None def _call_name(node: ast.AST) -> str: + """Run the call name helper.""" if isinstance(node, ast.Name): return node.id if isinstance(node, ast.Attribute): @@ -643,6 +673,7 @@ def _call_name(node: ast.AST) -> str: def _attribute_name(node: ast.Attribute) -> str: + """Run the attribute name helper.""" parts = [node.attr] value = node.value while isinstance(value, ast.Attribute): @@ -657,6 +688,7 @@ def _validate_binding_expressions( binding: DiscoveredSwaggerBinding, operation: SwaggerOperation, ) -> tuple[SwaggerReportError, ...]: + """Validate binding expressions.""" errors: list[SwaggerReportError] = [] errors.extend( _validate_expression_mapping( @@ -684,6 +716,7 @@ def _validate_expression_mapping( mapping: Mapping[str, str], subject: str, ) -> tuple[SwaggerReportError, ...]: + """Validate expression mapping.""" errors: list[SwaggerReportError] = [] for argument_name, expression in sorted(mapping.items()): errors.extend( @@ -706,6 +739,7 @@ def _validate_expression( argument_name: str, expression: str, ) -> tuple[SwaggerReportError, ...]: + """Validate expression.""" if expression == "body": if operation.request_body is None: return ( @@ -848,6 +882,7 @@ def _validate_parameter_expression( field_name: str, location: str, ) -> tuple[SwaggerReportError, ...]: + """Validate parameter expression.""" parameter_names = { parameter.name for parameter in operation.parameters if parameter.location == location } @@ -872,6 +907,7 @@ def _expression_error( code: str, message: str, ) -> SwaggerReportError: + """Run the expression error helper.""" return SwaggerReportError( code=code, message=message, @@ -881,6 +917,7 @@ def _expression_error( def _load_sdk_method(binding: DiscoveredSwaggerBinding) -> Callable[..., object] | None: + """Load sdk method.""" module = importlib.import_module(binding.module) cls = getattr(module, binding.class_name, None) method = getattr(cls, binding.method_name, None) @@ -888,6 +925,7 @@ def _load_sdk_method(binding: DiscoveredSwaggerBinding) -> Callable[..., object] def _has_runtime_deprecation(method: Callable[..., object] | None) -> bool: + """Return whether runtime deprecation.""" metadata = getattr(method, "__sdk_deprecation__", None) return isinstance(metadata, DeprecatedSdkSymbol) @@ -900,6 +938,7 @@ def _validate_signature_mapping( subject: str, code_prefix: str, ) -> tuple[SwaggerReportError, ...]: + """Validate signature mapping.""" parameters = _mappable_parameters(signature) parameter_names = set(parameters) errors: list[SwaggerReportError] = [] @@ -937,6 +976,7 @@ def _validate_signature_mapping( def _mappable_parameters( signature: inspect.Signature, ) -> Mapping[str, inspect.Parameter]: + """Run the mappable parameters helper.""" return { name: parameter for name, parameter in signature.parameters.items() diff --git a/avito/core/swagger_report.py b/avito/core/swagger_report.py index a89fe18..4751384 100644 --- a/avito/core/swagger_report.py +++ b/avito/core/swagger_report.py @@ -33,9 +33,18 @@ class SwaggerBindingReport: def to_dict(self) -> dict[str, object]: """Return JSON-compatible report data.""" - binding_groups = _group_bindings_by_operation_key(self.discovery.bindings) + sync_bindings = tuple(binding for binding in self.discovery.bindings if binding.variant == "sync") + async_bindings = tuple( + binding for binding in self.discovery.bindings if binding.variant == "async" + ) + binding_groups = _group_bindings_by_operation_key(sync_bindings) + async_binding_groups = _group_bindings_by_operation_key(async_bindings) operation_entries = [ - _build_operation_entry(operation, binding_groups.get(operation.key, ())) + _build_operation_entry( + operation, + binding_groups.get(operation.key, ()), + async_binding_groups.get(operation.key, ()), + ) for operation in self.registry.operations ] binding_entries = [_build_binding_entry(binding) for binding in self.discovery.bindings] @@ -55,6 +64,10 @@ def to_dict(self) -> dict[str, object]: "unbound": unbound_operations, "duplicate": duplicate_operations, "ambiguous": ambiguous_bindings, + "variants": { + "sync": _variant_summary(self.registry.operations, sync_bindings), + "async": _variant_summary(self.registry.operations, async_bindings), + }, }, "operations": operation_entries, "bindings": binding_entries, @@ -87,6 +100,7 @@ def build_swagger_binding_report( def _group_bindings_by_operation_key( bindings: Sequence[DiscoveredSwaggerBinding], ) -> Mapping[str, tuple[DiscoveredSwaggerBinding, ...]]: + """Run the group bindings by operation key helper.""" grouped: defaultdict[str, list[DiscoveredSwaggerBinding]] = defaultdict(list) for binding in bindings: if binding.operation_key is None: @@ -101,7 +115,9 @@ def _group_bindings_by_operation_key( def _build_operation_entry( operation: SwaggerOperation, bindings: tuple[DiscoveredSwaggerBinding, ...], + async_bindings: tuple[DiscoveredSwaggerBinding, ...] = (), ) -> dict[str, object]: + """Build operation entry.""" if not bindings: status = "unbound" binding_entry: object = None @@ -120,10 +136,15 @@ def _build_operation_entry( "deprecated": operation.deprecated, "status": status, "binding": binding_entry, + "bindings_by_variant": { + "sync": binding_entry, + "async": _variant_binding_entry(async_bindings), + }, } def _build_binding_entry(binding: DiscoveredSwaggerBinding) -> dict[str, object]: + """Build binding entry.""" return { "module": binding.module, "class": binding.class_name, @@ -139,11 +160,13 @@ def _build_binding_entry(binding: DiscoveredSwaggerBinding) -> dict[str, object] "method_args": dict(binding.method_args), "deprecated": binding.deprecated, "legacy": binding.legacy, + "variant": binding.variant, "status": "ambiguous" if binding.operation_key is None else "mapped", } def _binding_reference(binding: DiscoveredSwaggerBinding) -> dict[str, object]: + """Run the binding reference helper.""" return { "module": binding.module, "class": binding.class_name, @@ -152,7 +175,35 @@ def _binding_reference(binding: DiscoveredSwaggerBinding) -> dict[str, object]: } +def _variant_binding_entry(bindings: tuple[DiscoveredSwaggerBinding, ...]) -> object: + """Run the variant binding entry helper.""" + if not bindings: + return None + if len(bindings) == 1: + return _binding_reference(bindings[0]) + return [_binding_reference(binding) for binding in bindings] + + +def _variant_summary( + operations: tuple[SwaggerOperation, ...], + bindings: Sequence[DiscoveredSwaggerBinding], +) -> dict[str, int]: + """Run the variant summary helper.""" + groups = _group_bindings_by_operation_key(bindings) + bound = sum(1 for operation in operations if len(groups.get(operation.key, ())) == 1) + duplicate = sum(1 for operation_bindings in groups.values() if len(operation_bindings) > 1) + ambiguous = sum(1 for binding in bindings if binding.operation_key is None) + return { + "operations_total": len(operations), + "bound": bound, + "unbound": len(operations) - bound, + "duplicate": duplicate, + "ambiguous": ambiguous, + } + + def _build_registry_error_entry(error: SwaggerValidationError) -> dict[str, object]: + """Build registry error entry.""" return { "code": error.code, "message": error.message, @@ -162,6 +213,7 @@ def _build_registry_error_entry(error: SwaggerValidationError) -> dict[str, obje def _build_report_error_entry(error: SwaggerReportError) -> dict[str, object]: + """Build report error entry.""" return { "code": error.code, "message": error.message, diff --git a/avito/core/transport.py b/avito/core/transport.py index d6be29b..cd9fdcf 100644 --- a/avito/core/transport.py +++ b/avito/core/transport.py @@ -17,6 +17,7 @@ import httpx +from avito.core import _transport_shared as shared from avito.core.exceptions import ( AuthenticationError, AuthorizationError, @@ -61,12 +62,7 @@ def build_httpx_timeout(timeouts: ApiTimeouts) -> httpx.Timeout: """Преобразует SDK-конфигурацию таймаутов в `httpx.Timeout`.""" - return httpx.Timeout( - connect=timeouts.connect, - read=timeouts.read, - write=timeouts.write, - pool=timeouts.pool, - ) + return shared.build_httpx_timeout(timeouts) class Transport: @@ -80,6 +76,7 @@ def __init__( client: httpx.Client | None = None, sleep: Callable[[float], None] = time.sleep, ) -> None: + """Initialize Transport.""" self._settings = settings self._auth_provider = auth_provider self._retry_policy = settings.retry_policy @@ -134,10 +131,16 @@ def request( """Выполняет запрос и возвращает успешный `httpx.Response`.""" normalized_path = self._normalize_path(path) + bearer_token = ( + self._auth_provider.get_access_token() + if context.requires_auth and self._auth_provider is not None + else None + ) request_headers = self._merge_headers( context=context, headers=headers, idempotency_key=idempotency_key, + bearer_token=bearer_token, ) timeout = build_httpx_timeout(context.timeout or self._settings.timeouts) attempt = 0 @@ -363,6 +366,7 @@ def download_binary( ) def _normalize_path(self, path: str) -> str: + """Normalize path.""" stripped = path.strip() if not stripped: return "/" @@ -378,6 +382,7 @@ def _normalize_path(self, path: str) -> str: return normalized def _normalize_params(self, params: Mapping[str, object] | None) -> QueryParams | None: + """Normalize params.""" if params is None: return None normalized: dict[str, QueryParamValue] = {} @@ -391,16 +396,19 @@ def _normalize_params(self, params: Mapping[str, object] | None) -> QueryParams return normalized def _normalize_query_scalar(self, value: object) -> QueryScalar: + """Normalize query scalar.""" if isinstance(value, str | int | float | bool): return value return str(value) def _normalize_files(self, files: Mapping[str, object] | None) -> RequestFiles | None: + """Normalize files.""" if files is None: return None return {key: self._normalize_file_value(value) for key, value in files.items()} def _normalize_file_value(self, value: object) -> FileValue: + """Normalize file value.""" if isinstance(value, bytes | str | BytesIO): return value if isinstance(value, tuple): @@ -413,21 +421,19 @@ def _merge_headers( context: RequestContext, headers: Mapping[str, str] | None, idempotency_key: str | None, + bearer_token: str | None, ) -> dict[str, str]: - merged: dict[str, str] = { - "Accept": "application/json", - "User-Agent": self._user_agent, - } - merged.update(dict(context.headers)) - if headers is not None: - merged.update(dict(headers)) - if idempotency_key is not None: - merged["Idempotency-Key"] = idempotency_key - if context.requires_auth and self._auth_provider is not None: - merged["Authorization"] = f"Bearer {self._auth_provider.get_access_token()}" - return merged + """Run the merge headers helper.""" + return shared.merge_headers( + context=context, + headers=headers, + idempotency_key=idempotency_key, + user_agent=self._user_agent, + bearer_token=bearer_token, + ) def _build_user_agent(self) -> str: + """Build user agent.""" try: package_version = importlib_metadata.version("avito-py") except importlib_metadata.PackageNotFoundError: @@ -450,6 +456,7 @@ def _decide_transport_retry( is_timeout: bool, idempotency_key: str | None, ) -> RetryDecision: + """Decide transport retry.""" if attempt >= self._retry_policy.max_attempts: return RetryDecision(False) if not self._retry_policy.retry_on_transport_error: @@ -475,6 +482,7 @@ def _decide_http_retry( response: httpx.Response, idempotency_key: str | None, ) -> RetryDecision: + """Decide http retry.""" if attempt >= self._retry_policy.max_attempts: return RetryDecision(False) if not self._is_retryable_request( @@ -507,6 +515,7 @@ def _is_retryable_request( context: RequestContext, idempotency_key: str | None, ) -> bool: + """Return whether retryable request.""" if context.retry_disabled: return False normalized_method = method.upper() @@ -530,6 +539,7 @@ def _map_http_error( operation: str | None = None, attempt: int | None = None, ) -> Exception: + """Map http error.""" payload = self._safe_payload(response) message = self._extract_message(payload) or f"HTTP {response.status_code}" error_code = self._extract_error_code(payload) @@ -659,6 +669,7 @@ def _map_http_error( ) def _safe_payload(self, response: httpx.Response) -> object: + """Return a safe payload.""" content_type = response.headers.get("content-type", "") if "application/json" in content_type: try: @@ -668,6 +679,7 @@ def _safe_payload(self, response: httpx.Response) -> object: return response.text def _extract_message(self, payload: object) -> str | None: + """Extract message.""" if isinstance(payload, dict): for key in ("message", "error_description", "error", "detail"): value = payload.get(key) @@ -678,12 +690,14 @@ def _extract_message(self, payload: object) -> str | None: return None def _extract_error_code(self, payload: object) -> str | None: + """Extract error code.""" if not isinstance(payload, dict): return None value = payload.get("code") or payload.get("error") return value if isinstance(value, str) else None def _extract_error_details(self, payload: object) -> object | None: + """Extract error details.""" if not isinstance(payload, Mapping): return None for key in ("details", "fields", "errors", "violations"): @@ -693,6 +707,7 @@ def _extract_error_details(self, payload: object) -> object | None: return None def _extract_request_id(self, headers: Mapping[str, str]) -> str | None: + """Extract request id.""" for key in ("x-request-id", "x-correlation-id", "x-amzn-requestid"): value = headers.get(key) if value: @@ -700,6 +715,7 @@ def _extract_request_id(self, headers: Mapping[str, str]) -> str | None: return None def _get_retry_after_seconds(self, headers: Mapping[str, str]) -> float: + """Return retry after seconds.""" raw_value = headers.get("retry-after") if raw_value is None: return _MIN_RETRY_AFTER_SECONDS @@ -724,6 +740,7 @@ def _log_retry( status: int | None, decision: RetryDecision, ) -> None: + """Log retry.""" _LOGGER.info( "transport retry", extra={ @@ -748,6 +765,7 @@ def _log_http_exchange( latency_ms: int, request_id: str | None, ) -> None: + """Log http exchange.""" _LOGGER.debug( "transport http exchange", extra={ @@ -762,15 +780,18 @@ def _log_http_exchange( ) def _elapsed_ms(self, started_at: float) -> int: + """Return elapsed ms.""" return max(int((time.perf_counter() - started_at) * 1000), 0) def _safe_endpoint(self, endpoint: str) -> str: + """Return a safe endpoint.""" parsed = urlsplit(endpoint) if parsed.scheme or parsed.netloc: return parsed.path or "/" return endpoint def _extract_filename(self, content_disposition: str | None) -> str | None: + """Extract filename.""" if content_disposition is None: return None message = Message() diff --git a/avito/cpa/__init__.py b/avito/cpa/__init__.py index e517d26..798c70c 100644 --- a/avito/cpa/__init__.py +++ b/avito/cpa/__init__.py @@ -1,5 +1,12 @@ """Пакет cpa.""" +from avito.cpa.async_domain import ( + AsyncCallTrackingCall, + AsyncCpaArchive, + AsyncCpaCall, + AsyncCpaChat, + AsyncCpaLead, +) from avito.cpa.domain import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead from avito.cpa.models import ( CallTrackingCallInfo, @@ -28,6 +35,11 @@ ) __all__ = ( + "AsyncCallTrackingCall", + "AsyncCpaArchive", + "AsyncCpaCall", + "AsyncCpaChat", + "AsyncCpaLead", "CallTrackingCall", "CallTrackingCallInfo", "CallTrackingCallResponse", diff --git a/avito/cpa/async_domain.py b/avito/cpa/async_domain.py new file mode 100644 index 0000000..1652b5a --- /dev/null +++ b/avito/cpa/async_domain.py @@ -0,0 +1,795 @@ +"""Async-доменные объекты пакета cpa.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from avito.core import ApiTimeouts, RetryOverride, ValidationError +from avito.core.deprecation import deprecated_method, warn_deprecated_once +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation +from avito.core.validation import DateInput, serialize_iso_datetime +from avito.cpa.models import ( + CallTrackingCallResponse, + CallTrackingCallsRequest, + CallTrackingCallsResult, + CallTrackingGetCallByIdRequest, + CallTrackingRecord, + CpaActionResult, + CpaAudioRecord, + CpaBalanceInfo, + CpaBalanceInfoRequest, + CpaCallByIdRequest, + CpaCallComplaintRequest, + CpaCallInfo, + CpaCallsByTimeRequest, + CpaCallsResult, + CpaChatInfo, + CpaChatsByTimeRequest, + CpaChatsResult, + CpaLeadComplaintRequest, + CpaPhonesFromChatsRequest, + CpaPhonesResult, +) +from avito.cpa.operations import ( + CPA_HEADERS, + CREATE_CPA_CALL_COMPLAINT, + CREATE_CPA_LEAD_COMPLAINT, + GET_CALLTRACKING_CALL_BY_ID, + GET_CALLTRACKING_CALLS, + GET_CALLTRACKING_RECORD, + GET_CPA_ARCHIVE_BALANCE, + GET_CPA_ARCHIVE_CALL_BY_ID, + GET_CPA_ARCHIVE_RECORD, + GET_CPA_BALANCE, + GET_CPA_CHAT_BY_ACTION_ID, + GET_CPA_PHONES_INFO, + LIST_CPA_CALLS, + LIST_CPA_CHATS, + LIST_CPA_CHATS_CLASSIC, +) + + +@dataclass(slots=True, frozen=True) +class AsyncCpaLead(AsyncDomainObject): + """Доменный объект CPA-лида и связанных lead-операций.""" + + __swagger_domain__ = "cpa" + __sdk_factory__ = "cpa_lead" + + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/cpa/v1/createComplaintByActionId", + spec="CPAАвито.json", + operation_id="createComplaintByActionId", + variant="async", + method_args={"action_id": "body.action_id", "reason": "body.message"}, + ) + async def create_complaint_by_action_id( + self, + *, + action_id: int, + reason: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaActionResult: + """Создает жалобу по идентификатору CPA-действия. + + Аргументы: + action_id: идентифицирует CPA-действие. + reason: передает причину жалобы или обращения. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaActionResult` со статусом выполнения операции. + + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_CPA_LEAD_COMPLAINT, + request=CpaLeadComplaintRequest(action_id=action_id, reason=reason), + headers=CPA_HEADERS, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/cpa/v3/balanceInfo", + spec="CPAАвито.json", + operation_id="balanceInfoV3", + variant="async", + ) + async def get_balance_info( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> CpaBalanceInfo: + """Возвращает balance info для CPA-лидов. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaBalanceInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_CPA_BALANCE, + request=CpaBalanceInfoRequest(), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncCpaChat(AsyncDomainObject): + """Доменный объект CPA-чата.""" + + __swagger_domain__ = "cpa" + __sdk_factory__ = "cpa_chat" + __sdk_factory_args__ = {"chat_id": "path.chat_id"} + + action_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/cpa/v1/chatByActionId/{actionId}", + spec="CPAАвито.json", + operation_id="chatByActionId", + variant="async", + ) + async def get( + self, + *, + action_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaChatInfo: + """Возвращает CPA-чатов. + + Аргументы: + action_id: идентифицирует CPA-действие. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaChatInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_CPA_CHAT_BY_ACTION_ID, + path_params={"actionId": action_id or self._require_action_id()}, + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/cpa/v2/chatsByTime", + spec="CPAАвито.json", + operation_id="chatsByTime", + variant="async", + method_args={ + "created_at_from": "body.dateTimeFrom", + "limit": "body.limit", + "offset": "body.offset", + }, + ) + async def list( + self, + *, + created_at_from: DateInput, + limit: int, + offset: int, + version: int = 2, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaChatsResult: + """Возвращает список CPA-чатов. + + Аргументы: + created_at_from: задает нижнюю границу времени создания. + limit: ограничивает размер возвращаемой выборки. + offset: задает смещение первой записи в выборке. + version: задает версию upstream-контракта, если операция ее поддерживает. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaChatsResult` с типизированными данными ответа API. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + if version == 1: + return await self.list_classic( + created_at_from=created_at_from, + limit=limit, + offset=offset, + timeout=timeout, + retry=retry, + ) + return await self._execute( + LIST_CPA_CHATS, + request=CpaChatsByTimeRequest( + created_at_from=serialize_iso_datetime("created_at_from", created_at_from), + limit=limit, + offset=offset, + ), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/cpa/v1/chatsByTime", + spec="CPAАвито.json", + operation_id="chatsByTime", + variant="async", + method_args={ + "created_at_from": "body.dateTimeFrom", + "limit": "body.limit", + "offset": "body.offset", + }, + ) + async def list_classic( + self, + *, + created_at_from: DateInput, + limit: int, + offset: int, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaChatsResult: + """Выполняет legacy-операцию списка CPA-чатов v1 и возвращает типизированную SDK-модель. + + Аргументы: + created_at_from: фильтрует CPA-чаты по нижней границе даты создания. + limit: задает максимальное число записей в ответе. + offset: задает смещение выборки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaChatsResult` со списком CPA-чатов legacy API. + + Поведение: + Метод оставлен для явного покрытия отдельной Swagger operation. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + warn_deprecated_once( + symbol="AsyncCpaChat.list(version=1)", + replacement="await cpa_chat().list(version=2)", + removal_version="1.3.0", + deprecated_since="1.1.0", + ) + return await self._execute( + LIST_CPA_CHATS_CLASSIC, + request=CpaChatsByTimeRequest( + created_at_from=serialize_iso_datetime("created_at_from", created_at_from), + limit=limit, + offset=offset, + ), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/cpa/v1/phonesInfoFromChats", + spec="CPAАвито.json", + operation_id="phonesInfoFromChats", + variant="async", + method_args={ + "date_time_from": "body.dateTimeFrom", + "limit": "body.limit", + "offset": "body.offset", + }, + ) + async def get_phones_info_from_chats( + self, + *, + date_time_from: DateInput, + limit: int, + offset: int, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaPhonesResult: + """Возвращает phones info from chats для CPA-чатов. + + Аргументы: + date_time_from: задает нижнюю границу времени поиска. + limit: ограничивает размер возвращаемой выборки. + offset: задает смещение первой записи в выборке. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaPhonesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_CPA_PHONES_INFO, + request=CpaPhonesFromChatsRequest( + date_time_from=serialize_iso_datetime("date_time_from", date_time_from), + limit=limit, + offset=offset, + ), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) + + def _require_action_id(self) -> str: + """Validate required action id.""" + if self.action_id is None: + raise ValidationError("Для операции требуется `action_id`.") + return str(self.action_id) + + +@dataclass(slots=True, frozen=True) +class AsyncCpaCall(AsyncDomainObject): + """Доменный объект CPA-звонка.""" + + __swagger_domain__ = "cpa" + __sdk_factory__ = "cpa_call" + + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/cpa/v2/callsByTime", + spec="CPAАвито.json", + operation_id="getCallsByTimeV2", + variant="async", + method_args={ + "date_time_from": "body.dateTimeFrom", + "limit": "body.limit", + }, + ) + async def list( + self, + *, + date_time_from: DateInput, + limit: int, + offset: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaCallsResult: + """Возвращает список CPA-звонков. + + Аргументы: + date_time_from: задает начало временного интервала. + limit: ограничивает размер возвращаемой выборки. + offset: задает смещение первой записи в выборке. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaCallsResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + LIST_CPA_CALLS, + request=CpaCallsByTimeRequest( + date_time_from=serialize_iso_datetime("date_time_from", date_time_from), + limit=limit, + offset=offset, + ), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/cpa/v1/createComplaint", + spec="CPAАвито.json", + operation_id="postCreateComplaint", + variant="async", + method_args={"call_id": "body.call_id", "reason": "body.message"}, + ) + async def create_complaint( + self, + *, + call_id: int, + reason: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaActionResult: + """Создает жалобу по CPA-звонку. + + Аргументы: + call_id: идентифицирует звонок. + reason: передает причину жалобы или обращения. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaActionResult` со статусом выполнения операции. + + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_CPA_CALL_COMPLAINT, + request=CpaCallComplaintRequest(call_id=call_id, reason=reason), + headers=CPA_HEADERS, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncCpaArchive(AsyncDomainObject): + """Доменный объект архивных операций CPA.""" + + __swagger_domain__ = "cpa" + __sdk_factory__ = "cpa_archive" + __sdk_factory_args__ = {"call_id": "path.call_id"} + + call_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/cpa/v1/call/{call_id}", + spec="CPAАвито.json", + operation_id="getCall", + variant="async", + deprecated=True, + legacy=True, + ) + @deprecated_method( + symbol="AsyncCpaArchive.get_call", + replacement="await call_tracking_call().download", + removal_version="1.3.0", + deprecated_since="1.1.0", + ) + async def get_call( + self, + *, + call_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaAudioRecord: + """Получает архивную запись звонка. + + Deprecated: используйте `call_tracking_call().download`; удаление в версии 1.3.0. + + Аргументы: + call_id: идентифицирует архивную запись звонка. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaAudioRecord` с бинарной записью звонка. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return CpaAudioRecord( + await self._execute( + GET_CPA_ARCHIVE_RECORD, + path_params={"call_id": call_id or self._require_call_id()}, + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) + ) + + @swagger_operation( + "POST", + "/cpa/v2/balanceInfo", + spec="CPAАвито.json", + operation_id="balanceInfoV2", + variant="async", + deprecated=True, + legacy=True, + ) + @deprecated_method( + symbol="AsyncCpaArchive.get_balance_info", + replacement="await cpa_lead().get_balance_info", + removal_version="1.3.0", + deprecated_since="1.1.0", + ) + async def get_balance_info( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> CpaBalanceInfo: + """Получает архивный баланс CPA. + + Deprecated: используйте `cpa_lead().get_balance_info`; удаление в версии 1.3.0. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaBalanceInfo` с архивной информацией о балансе CPA. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_CPA_ARCHIVE_BALANCE, + request=CpaBalanceInfoRequest(), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/cpa/v2/callById", + spec="CPAАвито.json", + operation_id="getCallByIdV2", + variant="async", + method_args={"call_id": "body.call_id"}, + deprecated=True, + legacy=True, + ) + @deprecated_method( + symbol="AsyncCpaArchive.get_call_by_id", + replacement="await call_tracking_call().get", + removal_version="1.3.0", + deprecated_since="1.1.0", + ) + async def get_call_by_id( + self, + *, + call_id: int, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaCallInfo: + """Получает архивные данные звонка. + + Deprecated: используйте `call_tracking_call().get`; удаление в версии 1.3.0. + + Аргументы: + call_id: идентифицирует звонок. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaCallInfo` с архивными данными звонка. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_CPA_ARCHIVE_CALL_BY_ID, + request=CpaCallByIdRequest(call_id=call_id), + headers=CPA_HEADERS, + timeout=timeout, + retry=retry, + ) + + def _require_call_id(self) -> str: + """Validate required call id.""" + if self.call_id is None: + raise ValidationError("Для операции требуется `call_id`.") + return str(self.call_id) + + +@dataclass(slots=True, frozen=True) +class AsyncCallTrackingCall(AsyncDomainObject): + """Доменный объект CallTracking.""" + + __swagger_domain__ = "cpa" + __sdk_factory__ = "call_tracking_call" + __sdk_factory_args__ = {"call_id": "path.call_id"} + + call_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/calltracking/v1/getCallById", + spec="CallTracking[КТ].json", + operation_id="get_call_by_id", + variant="async", + ) + async def get( + self, + *, + call_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CallTrackingCallResponse: + """Возвращает call tracking звонков. + + Аргументы: + call_id: идентифицирует звонок. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CallTrackingCallResponse` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_call_id = call_id or (int(self.call_id) if self.call_id is not None else None) + if resolved_call_id is None: + raise ValidationError("Для операции требуется `call_id`.") + return await self._execute( + GET_CALLTRACKING_CALL_BY_ID, + request=CallTrackingGetCallByIdRequest(call_id=resolved_call_id), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/calltracking/v1/getCalls", + spec="CallTracking[КТ].json", + operation_id="get_calls", + variant="async", + method_args={"date_time_from": "body.date_time_from", "date_time_to": "body.date_time_to"}, + ) + async def list( + self, + *, + date_time_from: DateInput, + date_time_to: DateInput, + limit: int | None = None, + offset: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CallTrackingCallsResult: + """Возвращает список call tracking звонков. + + Аргументы: + date_time_from: задает начало временного интервала. + date_time_to: задает конец временного интервала. + limit: ограничивает размер возвращаемой выборки. + offset: задает смещение первой записи в выборке. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CallTrackingCallsResult` с типизированными данными ответа API. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_CALLTRACKING_CALLS, + request=CallTrackingCallsRequest( + date_time_from=serialize_iso_datetime("date_time_from", date_time_from), + date_time_to=serialize_iso_datetime("date_time_to", date_time_to), + limit=limit, + offset=offset, + ), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/calltracking/v1/getRecordByCallId", + spec="CallTracking[КТ].json", + operation_id="get_record_by_call_id", + variant="async", + method_args={"call_id": "query.callId"}, + ) + async def download( + self, + *, + call_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CallTrackingRecord: + """Скачивает запись call tracking звонка. + + Аргументы: + call_id: идентифицирует звонок. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CallTrackingRecord` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return CallTrackingRecord( + await self._execute( + GET_CALLTRACKING_RECORD, + query={"callId": call_id or self._require_call_id()}, + timeout=timeout, + retry=retry, + ) + ) + + def _require_call_id(self) -> str: + """Validate required call id.""" + if self.call_id is None: + raise ValidationError("Для операции требуется `call_id`.") + return str(self.call_id) + + +__all__ = ("AsyncCallTrackingCall", "AsyncCpaArchive", "AsyncCpaCall", "AsyncCpaChat", "AsyncCpaLead") diff --git a/avito/jobs/__init__.py b/avito/jobs/__init__.py index 69a3491..ab0e1fe 100644 --- a/avito/jobs/__init__.py +++ b/avito/jobs/__init__.py @@ -1,5 +1,12 @@ """Пакет jobs.""" +from avito.jobs.async_domain import ( + AsyncApplication, + AsyncJobDictionary, + AsyncJobWebhook, + AsyncResume, + AsyncVacancy, +) from avito.jobs.domain import Application, JobDictionary, JobWebhook, Resume, Vacancy from avito.jobs.models import ( ApplicationActionRequest, @@ -49,6 +56,11 @@ "ApplicationStatesResult", "ApplicationViewedItem", "ApplicationViewedRequest", + "AsyncApplication", + "AsyncJobDictionary", + "AsyncJobWebhook", + "AsyncResume", + "AsyncVacancy", "JobActionResult", "JobActionStatus", "JobEnrichmentStatus", diff --git a/avito/jobs/async_domain.py b/avito/jobs/async_domain.py new file mode 100644 index 0000000..19c0ce0 --- /dev/null +++ b/avito/jobs/async_domain.py @@ -0,0 +1,1228 @@ +"""Async-доменные объекты пакета jobs.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from avito.core import ApiTimeouts, RetryOverride, ValidationError +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation +from avito.core.validation import DateInput, serialize_iso_datetime +from avito.jobs.models import ( + ApplicationActionRequest, + ApplicationIdsQuery, + ApplicationIdsRequest, + ApplicationIdsResult, + ApplicationsResult, + ApplicationStatesResult, + ApplicationViewedItem, + ApplicationViewedRequest, + ApplicationViewedRequestItem, + JobActionResult, + JobDictionariesResult, + JobDictionaryValuesResult, + JobWebhookInfo, + JobWebhooksResult, + JobWebhookUpdateRequest, + ResumeContactInfo, + ResumeInfo, + ResumeSearchQuery, + ResumesResult, + VacanciesQuery, + VacanciesResult, + VacancyArchiveRequest, + VacancyAutoRenewalRequest, + VacancyBillingTypeInput, + VacancyClassicCreateRequest, + VacancyClassicUpdateRequest, + VacancyCreateRequest, + VacancyEmploymentInput, + VacancyExperienceInput, + VacancyIdsRequest, + VacancyInfo, + VacancyProlongateRequest, + VacancyScheduleInput, + VacancyStatusesResult, + VacancyUpdateRequest, +) +from avito.jobs.operations import ( + APPLY_APPLICATION_ACTIONS, + ARCHIVE_VACANCY, + CREATE_VACANCY, + CREATE_VACANCY_CLASSIC, + DELETE_JOB_WEBHOOK, + GET_APPLICATION_IDS, + GET_APPLICATION_STATES, + GET_APPLICATIONS_BY_IDS, + GET_JOB_DICTIONARY, + GET_JOB_WEBHOOK, + GET_RESUME, + GET_RESUME_CONTACTS, + GET_VACANCIES_BY_IDS, + GET_VACANCY, + GET_VACANCY_STATUSES, + LIST_JOB_DICTIONARIES, + LIST_JOB_WEBHOOKS, + LIST_VACANCIES, + PROLONGATE_VACANCY, + SEARCH_RESUMES, + SET_APPLICATIONS_IS_VIEWED, + UPDATE_JOB_WEBHOOK, + UPDATE_VACANCY, + UPDATE_VACANCY_AUTO_RENEWAL, + UPDATE_VACANCY_CLASSIC, +) + + +@dataclass(slots=True, frozen=True) +class AsyncVacancy(AsyncDomainObject): + """Доменный объект вакансий.""" + + __swagger_domain__ = "jobs" + __sdk_factory__ = "vacancy" + __sdk_factory_args__ = {"vacancy_id": "path.vacancy_id"} + + vacancy_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/job/v2/vacancies", + spec="АвитоРабота.json", + operation_id="vacancyCreateV2", + method_args={"title": "body.title", "billing_type": "body.billing_type"}, + variant="async", + ) + async def create( + self, + *, + title: str, + billing_type: VacancyBillingTypeInput, + description: str | None = None, + business_area: int | None = None, + employment: VacancyEmploymentInput | None = None, + schedule: VacancyScheduleInput | None = None, + experience: VacancyExperienceInput | None = None, + version: int = 2, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobActionResult: + """Создает вакансию. + + Аргументы: + title: передает название вакансии. + billing_type: задает тип биллинга. + description: передает описание вакансии для legacy v1 operation. + business_area: задает сферу деятельности для legacy v1 operation. + employment: задает тип занятости для legacy v1 operation. + schedule: задает режим работы для legacy v1 operation. + experience: задает требуемый опыт для legacy v1 operation. + version: задает версию upstream-контракта, если операция ее поддерживает. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + if version == 1: + if ( + description is None + or business_area is None + or employment is None + or schedule is None + or experience is None + ): + raise ValidationError("Для создания вакансии v1 требуются поля Swagger.") + return await self.create_classic( + title=title, + description=description, + billing_type=billing_type, + business_area=business_area, + employment=employment, + schedule=schedule, + experience=experience, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return await self._execute( + CREATE_VACANCY, + request=VacancyCreateRequest(title=title, billing_type=billing_type), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/job/v1/vacancies", + spec="АвитоРабота.json", + operation_id="vacancyCreate", + method_args={ + "title": "body.name", + "description": "body.description", + "billing_type": "body.billing_type", + "business_area": "body.business_area", + "employment": "body.employment", + "schedule": "body.schedule.id", + "experience": "body.experience", + }, + variant="async", + ) + async def create_classic( + self, + *, + title: str, + description: str, + billing_type: VacancyBillingTypeInput, + business_area: int, + employment: VacancyEmploymentInput, + schedule: VacancyScheduleInput, + experience: VacancyExperienceInput, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobActionResult: + """Создает вакансию через legacy v1 operation. + + Аргументы: + title: передает название вакансии в Swagger поле `name`. + description: передает описание вакансии. + billing_type: задает тип биллинга. + business_area: задает сферу деятельности. + employment: задает тип занятости. + schedule: задает режим работы. + experience: задает требуемый опыт. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_VACANCY_CLASSIC, + request=VacancyClassicCreateRequest( + title=title, + description=description, + billing_type=billing_type, + business_area=business_area, + employment=employment, + schedule=schedule, + experience=experience, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/job/v2/vacancies/update/{vacancy_uuid}", + spec="АвитоРабота.json", + operation_id="vacancyUpdateV2", + method_args={"title": "body.title", "billing_type": "body.billing_type"}, + variant="async", + ) + async def update( + self, + *, + title: str, + billing_type: VacancyBillingTypeInput, + vacancy_id: int | str | None = None, + vacancy_uuid: str | None = None, + version: int = 2, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobActionResult: + """Обновляет вакансию. + + Аргументы: + title: передает название вакансии. + billing_type: задает тип биллинга. + vacancy_id: идентифицирует вакансию. + vacancy_uuid: идентифицирует вакансию по UUID. + version: задает версию upstream-контракта, если операция ее поддерживает. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + if version == 1: + return await self.update_classic( + vacancy_id=vacancy_id or self._require_vacancy_id(), + title=title, + billing_type=billing_type, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return await self._execute( + UPDATE_VACANCY, + path_params={"vacancy_uuid": vacancy_uuid or self._require_vacancy_id()}, + request=VacancyUpdateRequest(title=title, billing_type=billing_type), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "PUT", + "/job/v1/vacancies/{vacancy_id}", + spec="АвитоРабота.json", + operation_id="vacancyUpdate", + method_args={"title": "body.name", "billing_type": "body.billing_type"}, + variant="async", + ) + async def update_classic( + self, + *, + title: str, + billing_type: VacancyBillingTypeInput, + vacancy_id: int | str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobActionResult: + """Обновляет вакансию через legacy v1 operation. + + Аргументы: + title: передает название вакансии в Swagger поле `name`. + billing_type: задает тип биллинга. + vacancy_id: идентифицирует вакансию. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPDATE_VACANCY_CLASSIC, + path_params={"vacancy_id": vacancy_id or self._require_vacancy_id()}, + request=VacancyClassicUpdateRequest(title=title, billing_type=billing_type), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "PUT", + "/job/v1/vacancies/archived/{vacancy_id}", + spec="АвитоРабота.json", + operation_id="vacancyArchive", + method_args={"employee_id": "body.employee_id"}, + variant="async", + ) + async def delete( + self, + *, + employee_id: int, + vacancy_id: int | str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobActionResult: + """Удаляет вакансию. + + Аргументы: + employee_id: идентифицирует сотрудника аккаунта. + vacancy_id: идентифицирует вакансию. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + ARCHIVE_VACANCY, + path_params={"vacancy_id": vacancy_id or self._require_vacancy_id()}, + request=VacancyArchiveRequest(employee_id=employee_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/job/v1/vacancies/{vacancy_id}/prolongate", + spec="АвитоРабота.json", + operation_id="vacancyProlongate", + method_args={"billing_type": "body.billing_type"}, + variant="async", + ) + async def prolongate( + self, + *, + billing_type: VacancyBillingTypeInput, + vacancy_id: int | str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobActionResult: + """Продлевает вакансий. + + Аргументы: + billing_type: задает тип биллинга для продления вакансии. + vacancy_id: идентифицирует вакансию. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + PROLONGATE_VACANCY, + path_params={"vacancy_id": vacancy_id or self._require_vacancy_id()}, + request=VacancyProlongateRequest(billing_type=billing_type), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/job/v2/vacancies", + spec="АвитоРабота.json", + operation_id="searchVacancy", + variant="async", + ) + async def list( + self, + *, + query: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> VacanciesResult: + """Возвращает список вакансий. + + Аргументы: + query: передает поисковую строку или фильтр upstream API. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `VacanciesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + LIST_VACANCIES, + query=VacanciesQuery(query=query), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/job/v2/vacancies/{vacancy_id}", + spec="АвитоРабота.json", + operation_id="vacancyGetItem", + variant="async", + ) + async def get( + self, + *, + vacancy_id: int | str | None = None, + query: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> VacancyInfo: + """Возвращает вакансий. + + Аргументы: + vacancy_id: идентифицирует вакансию. + query: передает поисковую строку или фильтр upstream API. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `VacancyInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_VACANCY, + path_params={"vacancy_id": vacancy_id or self._require_vacancy_id()}, + query=VacanciesQuery(query=query), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/job/v2/vacancies/batch", + spec="АвитоРабота.json", + operation_id="vacanciesGetByIds", + method_args={"ids": "body.ids"}, + variant="async", + ) + async def get_by_ids( + self, + *, + ids: Sequence[int], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> VacanciesResult: + """Возвращает вакансий. + + Аргументы: + ids: передает идентификаторы объектов для пакетной операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `VacanciesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_VACANCIES_BY_IDS, + request=VacancyIdsRequest(ids=list(ids)), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/job/v2/vacancies/statuses", + spec="АвитоРабота.json", + operation_id="vacancyGetStatuses", + method_args={"ids": "body.ids"}, + variant="async", + ) + async def get_statuses( + self, + *, + ids: Sequence[str], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> VacancyStatusesResult: + """Возвращает statuses для вакансий. + + Аргументы: + ids: передает идентификаторы объектов для пакетной операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `VacancyStatusesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_VACANCY_STATUSES, + request=VacancyIdsRequest(ids=list(ids)), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "PUT", + "/job/v2/vacancies/{vacancy_uuid}/auto_renewal", + spec="АвитоРабота.json", + operation_id="vacancyAutoRenewal", + method_args={"auto_renewal": "body.auto_renewal"}, + variant="async", + ) + async def update_auto_renewal( + self, + *, + auto_renewal: bool, + vacancy_uuid: str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobActionResult: + """Обновляет настройку автопродления вакансии. + + Аргументы: + auto_renewal: включает или отключает автопродление вакансии. + vacancy_uuid: идентифицирует вакансию по UUID. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPDATE_VACANCY_AUTO_RENEWAL, + path_params={"vacancy_uuid": vacancy_uuid or self._require_vacancy_id()}, + request=VacancyAutoRenewalRequest(auto_renewal=auto_renewal), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + def _require_vacancy_id(self) -> str: + """Validate required vacancy id.""" + if self.vacancy_id is None: + raise ValidationError("Для операции требуется идентификатор вакансии.") + return str(self.vacancy_id) + + +@dataclass(slots=True, frozen=True) +class AsyncApplication(AsyncDomainObject): + """Доменный объект откликов.""" + + __swagger_domain__ = "jobs" + __sdk_factory__ = "application" + + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/job/v1/applications/apply_actions", + spec="АвитоРабота.json", + operation_id="applicationsApplyActions", + method_args={"ids": "body.ids", "action": "body.action"}, + variant="async", + ) + async def apply( + self, + *, + ids: Sequence[str], + action: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobActionResult: + """Применяет действие к откликов на вакансии. + + Аргументы: + ids: передает идентификаторы объектов для пакетной операции. + action: задает действие над откликами. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + APPLY_APPLICATION_ACTIONS, + request=ApplicationActionRequest(ids=list(ids), action=action), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/job/v1/applications/get_by_ids", + spec="АвитоРабота.json", + operation_id="applicationsGetByIds", + method_args={"ids": "body.ids"}, + variant="async", + ) + async def get_by_ids( + self, + *, + ids: Sequence[str], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ApplicationsResult: + """Возвращает отклики по идентификаторам и возвращает типизированную SDK-модель. + + Аргументы: + ids: передает идентификаторы откликов. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ApplicationsResult` со списком найденных откликов. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_APPLICATIONS_BY_IDS, + request=ApplicationIdsRequest(ids=list(ids)), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/job/v1/applications/get_ids", + spec="АвитоРабота.json", + operation_id="applicationsGetIds", + method_args={"updated_at_from": "query.updatedAtFrom"}, + variant="async", + ) + async def get_ids( + self, + *, + updated_at_from: DateInput, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ApplicationIdsResult: + """Возвращает идентификаторы откликов по фильтру и возвращает типизированную SDK-модель. + + Аргументы: + updated_at_from: фильтрует отклики по нижней границе даты обновления. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ApplicationIdsResult` со списком идентификаторов откликов. + + Поведение: + `updated_at_from` сериализуется в ISO datetime перед выполнением запроса. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_APPLICATION_IDS, + query=ApplicationIdsQuery( + updated_at_from=serialize_iso_datetime("updated_at_from", updated_at_from) + ), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/job/v1/applications/get_states", + spec="АвитоРабота.json", + operation_id="applicationsGetStates", + variant="async", + ) + async def get_states( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> ApplicationStatesResult: + """Возвращает states для откликов на вакансии. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ApplicationStatesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_APPLICATION_STATES, timeout=timeout, retry=retry) + + @swagger_operation( + "POST", + "/job/v1/applications/set_is_viewed", + spec="АвитоРабота.json", + operation_id="applicationsSetIsViewed", + method_args={"applies": "body.applies"}, + variant="async", + ) + async def update( + self, + *, + applies: Sequence[ApplicationViewedItem], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobActionResult: + """Обновляет отметки просмотра откликов на вакансии. + + Аргументы: + applies: передает список отметок просмотра откликов. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SET_APPLICATIONS_IS_VIEWED, + request=ApplicationViewedRequest( + applies=[ + ApplicationViewedRequestItem(id=item.id, is_viewed=item.is_viewed) + for item in applies + ] + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncResume(AsyncDomainObject): + """Доменный объект резюме.""" + + __swagger_domain__ = "jobs" + __sdk_factory__ = "resume" + __sdk_factory_args__ = {"resume_id": "path.resume_id"} + + resume_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/job/v1/resumes", + spec="АвитоРабота.json", + operation_id="resumesGet", + variant="async", + ) + async def list( + self, + *, + query: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ResumesResult: + """Возвращает список резюме. + + Аргументы: + query: передает поисковую строку или фильтр upstream API. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ResumesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SEARCH_RESUMES, + query=ResumeSearchQuery(query=query) if query is not None else None, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/job/v2/resumes/{resume_id}", + spec="АвитоРабота.json", + operation_id="resumeGetItem", + variant="async", + ) + async def get( + self, + *, + resume_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ResumeInfo: + """Возвращает резюме. + + Аргументы: + resume_id: идентифицирует резюме. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ResumeInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_RESUME, + path_params={"resume_id": str(resume_id or self._require_resume_id())}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/job/v1/resumes/{resume_id}/contacts", + spec="АвитоРабота.json", + operation_id="resumeGetContacts", + variant="async", + ) + async def get_contacts( + self, + *, + resume_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ResumeContactInfo: + """Возвращает contacts для резюме. + + Аргументы: + resume_id: идентифицирует резюме. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ResumeContactInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_RESUME_CONTACTS, + path_params={"resume_id": str(resume_id or self._require_resume_id())}, + timeout=timeout, + retry=retry, + ) + + def _require_resume_id(self) -> str: + """Validate required resume id.""" + if self.resume_id is None: + raise ValidationError("Для операции требуется `resume_id`.") + return str(self.resume_id) + + +@dataclass(slots=True, frozen=True) +class AsyncJobWebhook(AsyncDomainObject): + """Доменный объект webhook откликов.""" + + __swagger_domain__ = "jobs" + __sdk_factory__ = "job_webhook" + + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/job/v1/applications/webhook", + spec="АвитоРабота.json", + operation_id="applicationsWebhookGet", + variant="async", + ) + async def get( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> JobWebhookInfo: + """Возвращает webhook-уведомлений Авито Работы. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobWebhookInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_JOB_WEBHOOK, timeout=timeout, retry=retry) + + @swagger_operation( + "GET", + "/job/v1/applications/webhooks", + spec="АвитоРабота.json", + operation_id="applicationsWebhooksGet", + variant="async", + ) + async def list( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> JobWebhooksResult: + """Возвращает список webhook-уведомлений Авито Работы. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobWebhooksResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(LIST_JOB_WEBHOOKS, timeout=timeout, retry=retry) + + @swagger_operation( + "PUT", + "/job/v1/applications/webhook", + spec="АвитоРабота.json", + operation_id="applicationsWebhookPut", + method_args={"url": "body.url", "secret": "body.secret"}, + variant="async", + ) + async def update( + self, + *, + url: str, + secret: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobWebhookInfo: + """Обновляет webhook-уведомление Авито Работы. + + Аргументы: + url: задает URL webhook-подписки. + secret: задает секрет webhook-подписки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobWebhookInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPDATE_JOB_WEBHOOK, + request=JobWebhookUpdateRequest(url=url, secret=secret), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "DELETE", + "/job/v1/applications/webhook", + spec="АвитоРабота.json", + operation_id="applicationsWebhookDelete", + variant="async", + ) + async def delete( + self, + *, + url: str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobActionResult: + """Удаляет webhook-уведомление Авито Работы. + + Аргументы: + url: задает URL webhook-подписки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + DELETE_JOB_WEBHOOK, + query={"url": url} if url is not None else None, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncJobDictionary(AsyncDomainObject): + """Доменный объект словарей вакансий.""" + + __swagger_domain__ = "jobs" + __sdk_factory__ = "job_dictionary" + __sdk_factory_args__ = {"dictionary_id": "path.dictionary_id"} + + dictionary_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/job/v2/vacancy/dict", + spec="АвитоРабота.json", + operation_id="getDicts", + variant="async", + ) + async def list( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> JobDictionariesResult: + """Возвращает список справочников Авито Работы. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobDictionariesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(LIST_JOB_DICTIONARIES, timeout=timeout, retry=retry) + + @swagger_operation( + "GET", + "/job/v2/vacancy/dict/{dictionary_id}", + spec="АвитоРабота.json", + operation_id="getDictByID", + variant="async", + ) + async def get( + self, + *, + dictionary_id: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> JobDictionaryValuesResult: + """Возвращает справочников Авито Работы. + + Аргументы: + dictionary_id: идентифицирует справочник. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `JobDictionaryValuesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_JOB_DICTIONARY, + path_params={"dictionary_id": dictionary_id or self._require_dictionary_id()}, + timeout=timeout, + retry=retry, + ) + + def _require_dictionary_id(self) -> str: + """Validate required dictionary id.""" + if self.dictionary_id is None: + raise ValidationError("Для операции требуется `dictionary_id`.") + return str(self.dictionary_id) + + +__all__ = ( + "AsyncApplication", + "AsyncJobDictionary", + "AsyncJobWebhook", + "AsyncResume", + "AsyncVacancy", +) diff --git a/avito/messenger/__init__.py b/avito/messenger/__init__.py index e6530a0..c7ca149 100644 --- a/avito/messenger/__init__.py +++ b/avito/messenger/__init__.py @@ -1,5 +1,12 @@ """Пакет messenger.""" +from avito.messenger.async_domain import ( + AsyncChat, + AsyncChatMedia, + AsyncChatMessage, + AsyncChatWebhook, + AsyncSpecialOfferCampaign, +) from avito.messenger.domain import ( Chat, ChatMedia, @@ -33,6 +40,11 @@ ) __all__ = ( + "AsyncChat", + "AsyncChatMedia", + "AsyncChatMessage", + "AsyncChatWebhook", + "AsyncSpecialOfferCampaign", "Chat", "ChatInfo", "ChatMedia", diff --git a/avito/messenger/async_domain.py b/avito/messenger/async_domain.py new file mode 100644 index 0000000..30bd99d --- /dev/null +++ b/avito/messenger/async_domain.py @@ -0,0 +1,922 @@ +"""Async-доменные объекты пакета messenger.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from avito.core import ApiTimeouts, RetryOverride, ValidationError +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation +from avito.core.validation import DateInput, serialize_iso_datetime +from avito.messenger.models import ( + BlacklistRequest, + ChatInfo, + ChatsResult, + MessageActionResult, + MessagesResult, + MultiConfirmSpecialOfferRequest, + MultiCreateSpecialOfferRequest, + MultiCreateSpecialOfferResult, + SendImageMessageRequest, + SendMessageRequest, + SpecialOfferAvailableRequest, + SpecialOfferAvailableResult, + SpecialOfferStatsRequest, + SpecialOfferStatsResult, + SubscriptionsResult, + TariffInfo, + UnsubscribeWebhookRequest, + UpdateWebhookRequest, + UploadImageFile, + UploadImagesRequest, + UploadImagesResult, + VoiceFilesResult, + WebhookActionResult, +) +from avito.messenger.operations import ( + ADD_TO_BLACKLIST, + CONFIRM_MULTI_SPECIAL_OFFER, + CREATE_MULTI_SPECIAL_OFFER, + DELETE_MESSAGE, + GET_AVAILABLE_SPECIAL_OFFERS, + GET_CHAT, + GET_SPECIAL_OFFER_STATS, + GET_SPECIAL_OFFER_TARIFF_INFO, + GET_SUBSCRIPTIONS, + GET_VOICE_FILES, + LIST_CHATS, + LIST_MESSAGES, + READ_CHAT, + SEND_IMAGE_MESSAGE, + SEND_MESSAGE, + UNSUBSCRIBE_WEBHOOK, + UPDATE_WEBHOOK_V3, + UPLOAD_IMAGES, +) + + +@dataclass(slots=True, frozen=True) +class AsyncChat(AsyncDomainObject): + """Async-доменный объект чата.""" + + __swagger_domain__ = "messenger" + __sdk_factory__ = "chat" + __sdk_factory_args__ = {"chat_id": "path.chat_id", "user_id": "path.user_id"} + + chat_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/messenger/v2/accounts/{user_id}/chats/{chat_id}", + spec="Мессенджер.json", + operation_id="getChatByIdV2", + variant="async", + ) + async def get( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> ChatInfo: + """Получает чат по `chat_id` асинхронно. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ChatInfo` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_CHAT, + path_params={ + "user_id": self._require_user_id(), + "chat_id": self._require_chat_id(), + }, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/messenger/v2/accounts/{user_id}/chats", + spec="Мессенджер.json", + operation_id="getChatsV2", + variant="async", + ) + async def list( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> ChatsResult: + """Возвращает список чатов асинхронно. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ChatsResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + LIST_CHATS, + path_params={"user_id": self._require_user_id()}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/messenger/v1/accounts/{user_id}/chats/{chat_id}/read", + spec="Мессенджер.json", + operation_id="chatRead", + variant="async", + ) + async def mark_read( + self, + *, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MessageActionResult: + """Помечает чат как прочитанный асинхронно. + + Аргументы: + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MessageActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + READ_CHAT, + path_params={ + "user_id": self._require_user_id(), + "chat_id": self._require_chat_id(), + }, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/messenger/v2/accounts/{user_id}/blacklist", + spec="Мессенджер.json", + operation_id="postBlacklistV2", + method_args={"blacklisted_user_id": "body.users[].user_id"}, + variant="async", + ) + async def blacklist( + self, + *, + blacklisted_user_id: int, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MessageActionResult: + """Добавляет пользователя в blacklist асинхронно. + + Аргументы: + blacklisted_user_id: идентификатор пользователя, которого нужно добавить в черный список. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MessageActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + ADD_TO_BLACKLIST, + path_params={"user_id": self._require_user_id()}, + request=BlacklistRequest(blacklisted_user_id=blacklisted_user_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + def _require_user_id(self) -> int: + """Validate required user id.""" + if self.user_id is None: + raise ValidationError("Для операции требуется `user_id`.") + return int(self.user_id) + + def _require_chat_id(self) -> str: + """Validate required chat id.""" + if self.chat_id is None: + raise ValidationError("Для операции требуется `chat_id`.") + return str(self.chat_id) + + +@dataclass(slots=True, frozen=True) +class AsyncChatMessage(AsyncDomainObject): + """Async-доменный объект сообщения чата.""" + + __swagger_domain__ = "messenger" + __sdk_factory__ = "chat_message" + __sdk_factory_args__ = { + "message_id": "path.message_id", + "chat_id": "path.chat_id", + "user_id": "path.user_id", + } + + chat_id: int | str | None = None + message_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/messenger/v3/accounts/{user_id}/chats/{chat_id}/messages/", + spec="Мессенджер.json", + operation_id="getMessagesV3", + variant="async", + ) + async def list( + self, + *, + chat_id: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MessagesResult: + """Возвращает список сообщений чата асинхронно. + + Аргументы: + chat_id: идентифицирует чат. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MessagesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + LIST_MESSAGES, + path_params={ + "user_id": self._require_user_id(), + "chat_id": chat_id or self._require_chat_id(), + }, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/messenger/v1/accounts/{user_id}/chats/{chat_id}/messages", + spec="Мессенджер.json", + operation_id="postSendMessage", + method_args={"message": "body.message"}, + variant="async", + ) + async def send_message( + self, + *, + chat_id: str | None = None, + message: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MessageActionResult: + """Отправляет текстовое сообщение асинхронно. + + Аргументы: + chat_id: идентификатор чата. + message: текст отправляемого сообщения. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MessageActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SEND_MESSAGE, + path_params={ + "user_id": self._require_user_id(), + "chat_id": chat_id or self._require_chat_id(), + }, + request=SendMessageRequest(message=message), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/messenger/v1/accounts/{user_id}/chats/{chat_id}/messages/image", + spec="Мессенджер.json", + operation_id="postSendImageMessage", + method_args={"image_id": "body.image_id"}, + variant="async", + ) + async def send_image( + self, + *, + chat_id: str | None = None, + image_id: str, + caption: str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MessageActionResult: + """Отправляет сообщение с изображением асинхронно. + + Аргументы: + chat_id: идентификатор чата. + image_id: идентификатор изображения для отправки. + caption: подпись к изображению, если поддерживается операцией. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MessageActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SEND_IMAGE_MESSAGE, + path_params={ + "user_id": self._require_user_id(), + "chat_id": chat_id or self._require_chat_id(), + }, + request=SendImageMessageRequest(image_id=image_id, caption=caption), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/messenger/v1/accounts/{user_id}/chats/{chat_id}/messages/{message_id}", + spec="Мессенджер.json", + operation_id="deleteMessage", + variant="async", + ) + async def delete( + self, + *, + chat_id: str | None = None, + message_id: str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MessageActionResult: + """Удаляет сообщение асинхронно. + + Аргументы: + chat_id: идентификатор чата. + message_id: идентификатор сообщения. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MessageActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_message_id = message_id or self._require_message_id() + return await self._execute( + DELETE_MESSAGE, + path_params={ + "user_id": self._require_user_id(), + "chat_id": chat_id or self._require_chat_id(), + "message_id": resolved_message_id, + }, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + def _require_user_id(self) -> int: + """Validate required user id.""" + if self.user_id is None: + raise ValidationError("Для операции требуется `user_id`.") + return int(self.user_id) + + def _require_chat_id(self) -> str: + """Validate required chat id.""" + if self.chat_id is None: + raise ValidationError("Для операции требуется `chat_id`.") + return str(self.chat_id) + + def _require_message_id(self) -> str: + """Validate required message id.""" + if self.message_id is None: + raise ValidationError("Для операции требуется `message_id`.") + return str(self.message_id) + + +@dataclass(slots=True, frozen=True) +class AsyncChatWebhook(AsyncDomainObject): + """Async-доменный объект webhook мессенджера.""" + + __swagger_domain__ = "messenger" + __sdk_factory__ = "chat_webhook" + + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/messenger/v1/subscriptions", + spec="Мессенджер.json", + operation_id="getSubscriptions", + variant="async", + ) + async def list( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> SubscriptionsResult: + """Возвращает список webhook-подписок чатов асинхронно. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `SubscriptionsResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_SUBSCRIPTIONS, timeout=timeout, retry=retry) + + @swagger_operation( + "POST", + "/messenger/v1/webhook/unsubscribe", + spec="Мессенджер.json", + operation_id="postWebhookUnsubscribe", + method_args={"url": "body.url"}, + variant="async", + ) + async def unsubscribe( + self, + *, + url: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> WebhookActionResult: + """Отключает webhook асинхронно. + + Аргументы: + url: URL источника данных. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `WebhookActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UNSUBSCRIBE_WEBHOOK, + request=UnsubscribeWebhookRequest(url=url), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/messenger/v3/webhook", + spec="Мессенджер.json", + operation_id="postWebhookV3", + method_args={"url": "body.url"}, + variant="async", + ) + async def subscribe( + self, + *, + url: str, + secret: str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> WebhookActionResult: + """Включает webhook v3 асинхронно. + + Аргументы: + url: URL источника данных. + secret: секрет webhook-подписки для проверки входящих событий. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `WebhookActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPDATE_WEBHOOK_V3, + request=UpdateWebhookRequest(url=url, secret=secret), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncChatMedia(AsyncDomainObject): + """Async-доменный объект media-функций мессенджера.""" + + __swagger_domain__ = "messenger" + __sdk_factory__ = "chat_media" + __sdk_factory_args__ = {"user_id": "path.user_id"} + + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/messenger/v1/accounts/{user_id}/getVoiceFiles", + spec="Мессенджер.json", + operation_id="getVoiceFiles", + variant="async", + ) + async def get_voice_files( + self, + *, + voice_ids: Sequence[str] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> VoiceFilesResult: + """Получает голосовые сообщения асинхронно. + + Аргументы: + voice_ids: идентификаторы голосовых файлов. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `VoiceFilesResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_voice_ids = list(voice_ids or ["voice-1"]) + return await self._execute( + GET_VOICE_FILES, + path_params={"user_id": self._require_user_id()}, + query={"voice_ids": ",".join(resolved_voice_ids)}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/messenger/v1/accounts/{user_id}/uploadImages", + spec="Мессенджер.json", + operation_id="uploadImages", + method_args={"files": "body.uploadfile[]"}, + variant="async", + ) + async def upload_images( + self, + *, + files: list[UploadImageFile], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> UploadImagesResult: + """Загружает изображения для сообщений асинхронно. + + Аргументы: + files: файлы изображений для загрузки. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `UploadImagesResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPLOAD_IMAGES, + path_params={"user_id": self._require_user_id()}, + files=UploadImagesRequest(files=files).to_files(), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + def _require_user_id(self) -> int: + """Validate required user id.""" + if self.user_id is None: + raise ValidationError("Для операции требуется `user_id`.") + return int(self.user_id) + + +@dataclass(slots=True, frozen=True) +class AsyncSpecialOfferCampaign(AsyncDomainObject): + """Async-доменный объект рассылки скидок и спецпредложений.""" + + __swagger_domain__ = "messenger" + __sdk_factory__ = "special_offer_campaign" + __sdk_factory_args__ = {"campaign_id": "path.campaign_id"} + + campaign_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/special-offers/v1/available", + spec="Рассылкаскидокиспецпредложенийвмессенджере.json", + operation_id="openApiAvailable", + method_args={"item_ids": "body.item_ids"}, + variant="async", + ) + async def get_available( + self, + *, + item_ids: list[int], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> SpecialOfferAvailableResult: + """Получает объявления, доступные для рассылки, асинхронно. + + Аргументы: + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `SpecialOfferAvailableResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_AVAILABLE_SPECIAL_OFFERS, + request=SpecialOfferAvailableRequest(item_ids=item_ids), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/special-offers/v1/multiCreate", + spec="Рассылкаскидокиспецпредложенийвмессенджере.json", + operation_id="openApiMultiCreate", + method_args={"item_ids": "body.itemIds"}, + variant="async", + ) + async def create_multi( + self, + *, + item_ids: list[int], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> MultiCreateSpecialOfferResult: + """Создает рассылку спецпредложений асинхронно. + + Аргументы: + item_ids: передает список объявлений для рассылки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `MultiCreateSpecialOfferResult` с идентификатором и статусом рассылки. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_MULTI_SPECIAL_OFFER, + request=MultiCreateSpecialOfferRequest(item_ids=item_ids), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/special-offers/v1/multiConfirm", + spec="Рассылкаскидокиспецпредложенийвмессенджере.json", + operation_id="openApiMultiConfirm", + method_args={ + "dispatch_id": "body.dispatches[].dispatchId", + "recipients_count": "body.dispatches[].recipientsCount", + "offer_slug": "body.dispatches[].offerSlug", + }, + variant="async", + ) + async def confirm_multi( + self, + *, + dispatch_id: int, + recipients_count: int, + offer_slug: str, + discount_value: int | None = None, + expires_at: int | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> WebhookActionResult: + """Подтверждает и оплачивает рассылку асинхронно. + + Аргументы: + dispatch_id: идентифицирует рассылку. + recipients_count: задает число получателей рассылки. + offer_slug: задает выбранный вариант предложения. + discount_value: задает финальный размер скидки, если он применим. + expires_at: задает timestamp окончания предложения. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `WebhookActionResult` со статусом подтверждения. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CONFIRM_MULTI_SPECIAL_OFFER, + request=MultiConfirmSpecialOfferRequest( + dispatch_id=dispatch_id, + recipients_count=recipients_count, + offer_slug=offer_slug, + discount_value=discount_value, + expires_at=expires_at, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/special-offers/v1/stats", + spec="Рассылкаскидокиспецпредложенийвмессенджере.json", + operation_id="openApiStats", + method_args={ + "date_time_from": "body.dateTimeFrom", + "date_time_to": "body.dateTimeTo", + }, + variant="async", + ) + async def get_stats( + self, + *, + date_time_from: DateInput, + date_time_to: DateInput, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> SpecialOfferStatsResult: + """Получает статистику рассылки асинхронно. + + Аргументы: + date_time_from: задает начало периода в формате RFC3339. + date_time_to: задает конец периода в формате RFC3339. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `SpecialOfferStatsResult` со статистикой рассылки. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_SPECIAL_OFFER_STATS, + request=SpecialOfferStatsRequest( + date_time_from=serialize_iso_datetime("date_time_from", date_time_from), + date_time_to=serialize_iso_datetime("date_time_to", date_time_to), + ), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/special-offers/v1/tariffInfo", + spec="Рассылкаскидокиспецпредложенийвмессенджере.json", + operation_id="openApiTariffInfo", + variant="async", + ) + async def get_tariff_info( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> TariffInfo: + """Получает информацию о тарифе спецпредложений асинхронно. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `TariffInfo` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_SPECIAL_OFFER_TARIFF_INFO, timeout=timeout, retry=retry) + + def _require_campaign_id(self) -> str: + """Validate required campaign id.""" + if self.campaign_id is None: + raise ValidationError("Для операции требуется `campaign_id`.") + return str(self.campaign_id) + + +__all__ = ( + "AsyncChat", + "AsyncChatMedia", + "AsyncChatMessage", + "AsyncChatWebhook", + "AsyncSpecialOfferCampaign", +) diff --git a/avito/orders/__init__.py b/avito/orders/__init__.py index c023173..b584338 100644 --- a/avito/orders/__init__.py +++ b/avito/orders/__init__.py @@ -1,5 +1,13 @@ """Пакет orders.""" +from avito.orders.async_domain import ( + AsyncDeliveryOrder, + AsyncDeliveryTask, + AsyncOrder, + AsyncOrderLabel, + AsyncSandboxDelivery, + AsyncStock, +) from avito.orders.domain import ( DeliveryOrder, DeliveryTask, @@ -91,6 +99,12 @@ ) __all__ = ( + "AsyncDeliveryOrder", + "AsyncDeliveryTask", + "AsyncOrder", + "AsyncOrderLabel", + "AsyncSandboxDelivery", + "AsyncStock", "CourierRangesResult", "CustomAreaScheduleEntry", "CustomAreaScheduleRequest", diff --git a/avito/orders/async_domain.py b/avito/orders/async_domain.py new file mode 100644 index 0000000..9eeb261 --- /dev/null +++ b/avito/orders/async_domain.py @@ -0,0 +1,2276 @@ +"""Async-доменные объекты пакета orders.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from avito.core import ApiTimeouts, RetryOverride, ValidationError +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation +from avito.core.validation import DateInput, serialize_iso_datetime +from avito.orders.models import ( + AddSortingCentersRequest, + AddTariffV2Request, + AddTerminalsRequest, + CancelParcelRequest, + CancelSandboxParcelOptions, + CancelSandboxParcelRequest, + ChangeParcelApplication, + ChangeParcelOptions, + ChangeParcelRequest, + CourierRangesResult, + CustomAreaScheduleEntry, + CustomAreaScheduleRequest, + DeliveryAnnouncementRequest, + DeliveryAnnouncementTrackRequest, + DeliveryCancelAnnouncementRequest, + DeliveryDirection, + DeliveryEntityResult, + DeliveryParcelIdsRequest, + DeliveryParcelRequest, + DeliveryParcelResultRequest, + DeliverySandboxAnnouncementRequest, + DeliverySortingCentersResult, + DeliveryTariffZone, + DeliveryTaskInfo, + DeliveryTermsZone, + DeliveryTrackingOptions, + DeliveryTrackingRequest, + GetChangeParcelInfoRequest, + GetRegisteredParcelIdRequest, + GetSandboxParcelInfoRequest, + LabelPdfResult, + LabelTaskResult, + OrderAcceptReturnRequest, + OrderActionResult, + OrderApplyTransitionRequest, + OrderCncDetailsRequest, + OrderConfirmationCodeRequest, + OrderCourierRangeRequest, + OrderDeliveryProperties, + OrderLabelsRequest, + OrderMarkingsRequest, + OrdersResult, + OrderTrackingNumberRequest, + OrderTransition, + ProhibitOrderAcceptanceRequest, + RealAddress, + SandboxAnnouncementPackage, + SandboxAnnouncementParticipant, + SandboxArea, + SandboxAreasRequest, + SandboxCancelAnnouncementOptions, + SandboxCancelAnnouncementRequest, + SandboxConfirmationCodeRequest, + SandboxCreateAnnouncementOptions, + SandboxCreateAnnouncementRequest, + SandboxGetAnnouncementEventRequest, + SandboxParcelRequest, + SetOrderPropertiesRequest, + SetOrderRealAddressRequest, + SortingCenterUpload, + StockInfoRequest, + StockInfoResult, + StockUpdateEntry, + StockUpdateRequest, + StockUpdateResult, + TaggedSortingCenter, + TaggedSortingCentersRequest, + TerminalUpload, + TrackingAvitoEventType, + TrackingAvitoStatus, + UpdateTermsRequest, +) +from avito.orders.operations import ( + ACCEPT_RETURN_ORDER, + APPLY_TRANSITION, + CHECK_CONFIRMATION_CODE, + CREATE_LABELS, + CREATE_LABELS_EXTENDED, + DELIVERY_CANCEL_ANNOUNCEMENT, + DELIVERY_CHANGE_PARCEL_RESULT, + DELIVERY_CREATE_ANNOUNCEMENT, + DELIVERY_CREATE_PARCEL, + DELIVERY_UPDATE_CHANGE_PARCELS, + DOWNLOAD_LABEL, + GET_COURIER_DELIVERY_RANGE, + GET_DELIVERY_TASK, + GET_STOCK_INFO, + LIST_ORDERS, + SANDBOX_ADD_AREAS, + SANDBOX_ADD_SORTING_CENTER, + SANDBOX_ADD_TAGS_TO_SORTING_CENTER, + SANDBOX_ADD_TARIFF, + SANDBOX_ADD_TERMINALS, + SANDBOX_CANCEL_PARCEL, + SANDBOX_CANCEL_SANDBOX_ANNOUNCEMENT, + SANDBOX_CANCEL_SANDBOX_PARCEL, + SANDBOX_CHANGE_SANDBOX_PARCEL, + SANDBOX_CHECK_CONFIRMATION_CODE, + SANDBOX_CREATE_ANNOUNCEMENT, + SANDBOX_CREATE_PARCEL, + SANDBOX_CREATE_SANDBOX_ANNOUNCEMENT, + SANDBOX_GET_ANNOUNCEMENT_EVENT, + SANDBOX_GET_CHANGE_PARCEL_INFO, + SANDBOX_GET_PARCEL_INFO, + SANDBOX_GET_REGISTERED_PARCEL_ID, + SANDBOX_LIST_SORTING_CENTER, + SANDBOX_PROHIBIT_ORDER_ACCEPTANCE, + SANDBOX_SET_ORDER_PROPERTIES, + SANDBOX_SET_ORDER_REAL_ADDRESS, + SANDBOX_TRACK_ANNOUNCEMENT, + SANDBOX_TRACKING, + SANDBOX_UPDATE_CUSTOM_AREA_SCHEDULE, + SANDBOX_UPDATE_TERMS, + SET_CNC_DETAILS, + SET_COURIER_DELIVERY_RANGE, + SET_TRACKING_NUMBER, + UPDATE_MARKINGS, + UPDATE_STOCKS, +) + + +@dataclass(slots=True, frozen=True) +class AsyncOrder(AsyncDomainObject): + """Доменный объект заказа.""" + + __swagger_domain__ = "orders" + __sdk_factory__ = "order" + + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/order-management/1/orders", + spec="Управлениезаказами.json", + operation_id="getOrders", + variant="async", + ) + async def list( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> OrdersResult: + """Возвращает список заказов. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `OrdersResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(LIST_ORDERS, timeout=timeout, retry=retry) + + @swagger_operation( + "POST", + "/order-management/1/markings", + spec="Управлениезаказами.json", + operation_id="markings", + variant="async", + method_args={"order_id": "body.markings", "codes": "body.markings"}, + ) + async def update_markings( + self, + *, + order_id: str, + codes: Sequence[str], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> OrderActionResult: + """Обновляет коды маркировки заказа. + + Аргументы: + order_id: идентифицирует заказ. + codes: передает коды маркировки заказа. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `OrderActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPDATE_MARKINGS, + request=OrderMarkingsRequest(order_id=order_id, codes=list(codes)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/order-management/1/order/acceptReturnOrder", + spec="Управлениезаказами.json", + operation_id="acceptReturnOrder", + variant="async", + method_args={"order_id": "body.order_id", "postal_office_id": "body.terminal_number"}, + ) + async def accept_return_order( + self, + *, + order_id: str, + postal_office_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> OrderActionResult: + """Подтверждает return order для заказов. + + Аргументы: + order_id: идентифицирует заказ. + postal_office_id: идентифицирует почтовое отделение для возврата. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `OrderActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + ACCEPT_RETURN_ORDER, + request=OrderAcceptReturnRequest( + order_id=order_id, + postal_office_id=postal_office_id, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/order-management/1/order/applyTransition", + spec="Управлениезаказами.json", + operation_id="applyTransition", + variant="async", + method_args={"order_id": "body.order_id", "transition": "body.transition"}, + ) + async def apply( + self, + *, + order_id: str, + transition: OrderTransition | str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> OrderActionResult: + """Применяет действие к заказов. + + Аргументы: + order_id: идентифицирует заказ. + transition: задает переход статуса заказа. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `OrderActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + APPLY_TRANSITION, + request=OrderApplyTransitionRequest(order_id=order_id, transition=transition), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/order-management/1/order/checkConfirmationCode", + spec="Управлениезаказами.json", + operation_id="checkConfirmationCode", + variant="async", + method_args={"order_id": "body.parcel_id", "code": "body.confirm_code"}, + ) + async def check_confirmation_code( + self, + *, + order_id: str, + code: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> OrderActionResult: + """Проверяет confirmation code для заказов. + + Аргументы: + order_id: идентифицирует заказ. + code: передает код подтверждения. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `OrderActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CHECK_CONFIRMATION_CODE, + request=OrderConfirmationCodeRequest(order_id=order_id, code=code), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/order-management/1/order/cncSetDetails", + spec="Управлениезаказами.json", + operation_id="cncSetDetails", + variant="async", + method_args={"order_id": "body.id", "pickup_point_id": "body.marketplace_id"}, + ) + async def set_cnc_details( + self, + *, + order_id: str, + pickup_point_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> OrderActionResult: + """Устанавливает параметры click-and-collect для заказа. + + Аргументы: + order_id: идентифицирует заказ. + pickup_point_id: идентифицирует пункт выдачи click-and-collect. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `OrderActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SET_CNC_DETAILS, + request=OrderCncDetailsRequest(order_id=order_id, pickup_point_id=pickup_point_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/order-management/1/order/getCourierDeliveryRange", + spec="Управлениезаказами.json", + operation_id="getCourierDeliveryRange", + variant="async", + ) + async def get_courier_delivery_range( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> CourierRangesResult: + """Возвращает courier delivery range для заказов. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CourierRangesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_COURIER_DELIVERY_RANGE, + query={"orderId": "order-1"}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/order-management/1/order/setCourierDeliveryRange", + spec="Управлениезаказами.json", + operation_id="setCourierDeliveryRange", + variant="async", + method_args={"order_id": "body.order_id", "interval_id": "body.interval_type"}, + ) + async def set_courier_delivery_range( + self, + *, + order_id: str, + interval_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> OrderActionResult: + """Устанавливает интервал курьерской доставки заказа. + + Аргументы: + order_id: идентифицирует заказ. + interval_id: идентифицирует интервал курьерской доставки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `OrderActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SET_COURIER_DELIVERY_RANGE, + request=OrderCourierRangeRequest(order_id=order_id, interval_id=interval_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/order-management/1/order/setTrackingNumber", + spec="Управлениезаказами.json", + operation_id="setOrderTrackingNumber", + variant="async", + method_args={"order_id": "body.order_id", "tracking_number": "body.tracking_number"}, + ) + async def update_tracking_number( + self, + *, + order_id: str, + tracking_number: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> OrderActionResult: + """Обновляет трек-номер заказа. + + Аргументы: + order_id: идентифицирует заказ. + tracking_number: передает трек-номер отправления. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `OrderActionResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SET_TRACKING_NUMBER, + request=OrderTrackingNumberRequest( + order_id=order_id, + tracking_number=tracking_number, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncOrderLabel(AsyncDomainObject): + """Доменный объект генерации и загрузки этикеток.""" + + __swagger_domain__ = "orders" + __sdk_factory__ = "order_label" + __sdk_factory_args__ = {"task_id": "path.task_id"} + + task_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/order-management/1/orders/labels", + spec="Управлениезаказами.json", + operation_id="generateLabels", + variant="async", + method_args={"order_ids": "body.order_ids"}, + ) + async def create( + self, + *, + order_ids: Sequence[str], + extended: bool = False, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> LabelTaskResult: + """Создает задачу генерации ярлыков заказов. + + Аргументы: + order_ids: передает идентификаторы заказов. + extended: запрашивает расширенный вариант результата, если поддерживается API. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `LabelTaskResult` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + if extended: + return await self.create_extended( + order_ids=order_ids, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return await self._execute( + CREATE_LABELS, + request=OrderLabelsRequest(order_ids=list(order_ids)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/order-management/1/orders/labels/extended", + spec="Управлениезаказами.json", + operation_id="generateLabelsExtended", + variant="async", + method_args={"order_ids": "body.order_ids"}, + ) + async def create_extended( + self, + *, + order_ids: Sequence[str], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> LabelTaskResult: + """Запускает генерацию расширенных этикеток и возвращает типизированную SDK-модель. + + Аргументы: + order_ids: передает идентификаторы заказов для генерации этикеток. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `LabelTaskResult` с идентификатором задачи генерации расширенных этикеток. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_LABELS_EXTENDED, + request=OrderLabelsRequest(order_ids=list(order_ids)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/order-management/1/orders/labels/{taskID}/download", + spec="Управлениезаказами.json", + operation_id="downloadLabel", + variant="async", + ) + async def download( + self, + *, + task_id: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> LabelPdfResult: + """Скачивает PDF с ярлыками заказов. + + Аргументы: + task_id: идентифицирует асинхронную задачу. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `LabelPdfResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_task_id = task_id or self._require_task_id() + binary = await self._execute( + DOWNLOAD_LABEL, + path_params={"taskID": resolved_task_id}, + timeout=timeout, + retry=retry, + ) + return LabelPdfResult(binary=binary) + + def _require_task_id(self) -> str: + """Validate required task id.""" + if self.task_id is None: + raise ValidationError("Для операции требуется `task_id`.") + return str(self.task_id) + + +@dataclass(slots=True, frozen=True) +class AsyncDeliveryOrder(AsyncDomainObject): + """Доменный объект production API доставки.""" + + __swagger_domain__ = "orders" + __sdk_factory__ = "delivery_order" + + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/createAnnouncement", + spec="Доставка.json", + operation_id="CreateAnnouncement3PL", + variant="async", + method_args={"order_id": "body.announcement_id"}, + ) + async def create_announcement( + self, + *, + order_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Создает объявление доставки для заказа. + + Аргументы: + order_id: идентифицирует заказ. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + DELIVERY_CREATE_ANNOUNCEMENT, + request=DeliveryAnnouncementRequest(order_id=order_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/cancelAnnouncement", + spec="Доставка.json", + operation_id="CancelAnnouncement3PL", + variant="async", + method_args={"order_id": "body.announcement_id"}, + ) + async def delete( + self, + *, + order_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Удаляет сущность доставки заказа. + + Аргументы: + order_id: идентифицирует заказ. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + DELIVERY_CANCEL_ANNOUNCEMENT, + request=DeliveryCancelAnnouncementRequest(order_id=order_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/createParcel", + spec="Доставка.json", + operation_id="createParcel", + variant="async", + method_args={"order_id": "body.order_id", "parcel_id": "body.parcel_id"}, + ) + async def create( + self, + *, + order_id: str, + parcel_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Создает сущность доставки заказа. + + Аргументы: + order_id: идентифицирует заказ. + parcel_id: идентифицирует отправление. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + DELIVERY_CREATE_PARCEL, + request=DeliveryParcelRequest(order_id=order_id, parcel_id=parcel_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/sandbox/changeParcels", + spec="Доставка.json", + operation_id="ChangeParcels", + variant="async", + method_args={"parcel_ids": "body.applications"}, + ) + async def update_change_parcels( + self, + *, + parcel_ids: Sequence[str], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Обновляет отправления для изменения доставки. + + Аргументы: + parcel_ids: передает идентификаторы отправлений. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + DELIVERY_UPDATE_CHANGE_PARCELS, + request=DeliveryParcelIdsRequest(parcel_ids=list(parcel_ids)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery/order/changeParcelResult", + spec="Доставка.json", + operation_id="ChangeParcelResult", + variant="async", + method_args={"parcel_id": "body.id", "result": "body.status"}, + ) + async def create_change_parcel_result( + self, + *, + parcel_id: str, + result: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Создает результат изменения отправления доставки. + + Аргументы: + parcel_id: идентифицирует отправление. + result: передает результат обработки изменения отправления. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + DELIVERY_CHANGE_PARCEL_RESULT, + request=DeliveryParcelResultRequest(parcel_id=parcel_id, result=result), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncSandboxDelivery(AsyncDomainObject): + """Доменный объект sandbox API доставки.""" + + __swagger_domain__ = "orders" + __sdk_factory__ = "sandbox_delivery" + + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/delivery-sandbox/announcements/create", + spec="Доставка.json", + operation_id="CreateAnnouncement", + variant="async", + method_args={"order_id": "body.announcement_id"}, + ) + async def create_announcement( + self, + *, + order_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Создает announcement для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_CREATE_ANNOUNCEMENT, + request=DeliverySandboxAnnouncementRequest(order_id=order_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/announcements/track", + spec="Доставка.json", + operation_id="TrackAnnouncement", + variant="async", + method_args={"order_id": "body.announcement_id"}, + ) + async def track_announcement( + self, + *, + order_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Передает tracking-событие для announcement для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_TRACK_ANNOUNCEMENT, + request=DeliveryAnnouncementTrackRequest(order_id=order_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/areas/custom-schedule", + spec="Доставка.json", + operation_id="customAreaSchedule", + variant="async", + method_args={"items": "body"}, + ) + async def update_custom_area_schedule( + self, + *, + items: Sequence[CustomAreaScheduleEntry], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Обновляет custom area schedule для sandbox-доставки. + + Аргументы: + items: передает элементы пакетного запроса. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_UPDATE_CUSTOM_AREA_SCHEDULE, + request=CustomAreaScheduleRequest(items=list(items)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/cancelParcel", + spec="Доставка.json", + operation_id="cancelParcel", + variant="async", + method_args={"parcel_id": "body.parcel_id", "actor": "body.actor"}, + ) + async def cancel_parcel( + self, + *, + parcel_id: str, + actor: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Отменяет parcel для sandbox-доставки. + + Аргументы: + parcel_id: идентифицирует отправление. + actor: задает участника, от имени которого выполняется отмена. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_CANCEL_PARCEL, + request=CancelParcelRequest(parcel_id=parcel_id, actor=actor), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/order/checkConfirmationCode", + spec="Доставка.json", + operation_id="checkConfirmationCode", + variant="async", + method_args={"parcel_id": "body.parcel_id", "confirm_code": "body.confirm_code"}, + ) + async def check_confirmation_code( + self, + *, + parcel_id: str, + confirm_code: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Проверяет confirmation code для sandbox-доставки. + + Аргументы: + parcel_id: идентифицирует отправление. + confirm_code: передает код подтверждения sandbox-доставки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_CHECK_CONFIRMATION_CODE, + request=SandboxConfirmationCodeRequest( + parcel_id=parcel_id, + confirm_code=confirm_code, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/order/properties", + spec="Доставка.json", + operation_id="setOrderProperties", + variant="async", + method_args={"order_id": "body.order_id", "properties": "body.properties"}, + ) + async def set_order_properties( + self, + *, + order_id: str, + properties: OrderDeliveryProperties, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Устанавливает order properties для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + properties: передает свойства заказа доставки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_SET_ORDER_PROPERTIES, + request=SetOrderPropertiesRequest(order_id=order_id, properties=properties), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/order/realAddress", + spec="Доставка.json", + operation_id="setOrderRealAddress", + variant="async", + method_args={"order_id": "body.order_id", "address": "body.address"}, + ) + async def set_order_real_address( + self, + *, + order_id: str, + address: RealAddress, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Устанавливает order real address для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + address: передает фактический адрес заказа. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_SET_ORDER_REAL_ADDRESS, + request=SetOrderRealAddressRequest(order_id=order_id, address=address), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/order/tracking", + spec="Доставка.json", + operation_id="tracking", + variant="async", + method_args={ + "order_id": "body.order_id", + "avito_status": "body.avito_status", + "avito_event_type": "body.avito_event_type", + "provider_event_code": "body.provider_event_code", + "date": "body.date", + "location": "body.location", + }, + ) + async def tracking( + self, + *, + order_id: str, + avito_status: TrackingAvitoStatus | str, + avito_event_type: TrackingAvitoEventType | str, + provider_event_code: str, + date: DateInput, + location: str, + comment: str | None = None, + options: DeliveryTrackingOptions | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Выполняет действие `tracking` для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + avito_status: передает статус события Авито. + avito_event_type: передает тип события Авито. + provider_event_code: передает код события провайдера. + date: задает дату события. + location: передает местоположение события. + comment: передает комментарий к операции. + options: передает дополнительные параметры операции. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_TRACKING, + request=DeliveryTrackingRequest( + order_id=order_id, + avito_status=avito_status, + avito_event_type=avito_event_type, + provider_event_code=provider_event_code, + date=serialize_iso_datetime("date", date), + location=location, + comment=comment, + options=options, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/prohibitOrderAcceptance", + spec="Доставка.json", + operation_id="prohibitOrderAcceptance", + variant="async", + method_args={"order_id": "body.order_id"}, + ) + async def prohibit_order_acceptance( + self, + *, + order_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Запрещает прием order acceptance для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_PROHIBIT_ORDER_ACCEPTANCE, + request=ProhibitOrderAcceptanceRequest(order_id=order_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/delivery-sandbox/sorting-center", + spec="Доставка.json", + operation_id="GetSortingCenter", + variant="async", + ) + async def list_sorting_center( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> DeliverySortingCentersResult: + """Возвращает список sorting center для sandbox-доставки. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliverySortingCentersResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_LIST_SORTING_CENTER, + query={"deliveryProviders": "pochta"}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/tariffs/sorting-center", + spec="Доставка.json", + operation_id="AddSortingCenter", + variant="async", + method_args={"items": "body"}, + ) + async def add_sorting_center( + self, + *, + items: Sequence[SortingCenterUpload], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Добавляет sorting center для sandbox-доставки. + + Аргументы: + items: передает элементы пакетного запроса. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_ADD_SORTING_CENTER, + request=AddSortingCentersRequest(items=list(items)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/tariffs/{tariff_id}/areas", + spec="Доставка.json", + operation_id="AddAreasSandbox", + variant="async", + method_args={"tariff_id": "path.tariff_id", "areas": "body"}, + ) + async def add_areas( + self, + *, + tariff_id: str, + areas: Sequence[SandboxArea], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Добавляет areas для sandbox-доставки. + + Аргументы: + tariff_id: идентифицирует тариф доставки. + areas: передает зоны доставки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_ADD_AREAS, + path_params={"tariff_id": tariff_id}, + request=SandboxAreasRequest(areas=list(areas)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/tariffs/{tariff_id}/tagged-sorting-centers", + spec="Доставка.json", + operation_id="AddTagsToSortingCenter", + variant="async", + method_args={"tariff_id": "path.tariff_id", "items": "body"}, + ) + async def add_tags_to_sorting_center( + self, + *, + tariff_id: str, + items: Sequence[TaggedSortingCenter], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Добавляет tags to sorting center для sandbox-доставки. + + Аргументы: + tariff_id: идентифицирует тариф доставки. + items: передает элементы пакетного запроса. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_ADD_TAGS_TO_SORTING_CENTER, + path_params={"tariff_id": tariff_id}, + request=TaggedSortingCentersRequest(items=list(items)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/tariffs/{tariff_id}/terminals", + spec="Доставка.json", + operation_id="AddTerminalsSandbox", + variant="async", + method_args={"tariff_id": "path.tariff_id", "items": "body"}, + ) + async def add_terminals( + self, + *, + tariff_id: str, + items: Sequence[TerminalUpload], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Добавляет terminals для sandbox-доставки. + + Аргументы: + tariff_id: идентифицирует тариф доставки. + items: передает элементы пакетного запроса. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_ADD_TERMINALS, + path_params={"tariff_id": tariff_id}, + request=AddTerminalsRequest(items=list(items)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/tariffs/{tariff_id}/terms", + spec="Доставка.json", + operation_id="UpdateTerms", + variant="async", + method_args={"tariff_id": "path.tariff_id", "items": "body"}, + ) + async def update_terms( + self, + *, + tariff_id: str, + items: Sequence[DeliveryTermsZone], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Обновляет terms для sandbox-доставки. + + Аргументы: + tariff_id: идентифицирует тариф доставки. + items: передает элементы пакетного запроса. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_UPDATE_TERMS, + path_params={"tariff_id": tariff_id}, + request=UpdateTermsRequest(items=list(items)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/tariffsV2", + spec="Доставка.json", + operation_id="AddTariffSandboxV2", + variant="async", + method_args={ + "name": "body.name", + "delivery_provider_tariff_id": "body.delivery_provider_tariff_id", + "directions": "body.directions", + "tariff_zones": "body.tariff_zones", + "terms_zones": "body.terms_zones", + }, + ) + async def add_tariff( + self, + *, + name: str, + delivery_provider_tariff_id: str, + directions: Sequence[DeliveryDirection], + tariff_zones: Sequence[DeliveryTariffZone], + terms_zones: Sequence[DeliveryTermsZone], + tariff_type: str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Добавляет tariff для sandbox-доставки. + + Аргументы: + name: передает название сущности. + delivery_provider_tariff_id: идентифицирует тариф провайдера доставки. + directions: передает направления доставки. + tariff_zones: передает тарифные зоны. + terms_zones: передает зоны условий доставки. + tariff_type: задает тип тарифа. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_ADD_TARIFF, + request=AddTariffV2Request( + name=name, + delivery_provider_tariff_id=delivery_provider_tariff_id, + directions=list(directions), + tariff_zones=list(tariff_zones), + terms_zones=list(terms_zones), + tariff_type=tariff_type, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/v2/createParcel", + spec="Доставка.json", + operation_id="CreateSandboxParcelV2", + variant="async", + method_args={"order_id": "body.items", "parcel_id": "body.items"}, + ) + async def create_parcel( + self, + *, + order_id: str, + parcel_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Создает parcel для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + parcel_id: идентифицирует отправление. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_CREATE_PARCEL, + request=SandboxParcelRequest(order_id=order_id, parcel_id=parcel_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/v1/cancelAnnouncement", + spec="Доставка.json", + operation_id="v1cancelAnnouncement", + variant="async", + method_args={ + "announcement_id": "body.announcement_id", + "date": "body.date", + "options": "body.options", + }, + ) + async def cancel_sandbox_announcement( + self, + *, + announcement_id: str, + date: DateInput, + options: SandboxCancelAnnouncementOptions, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Отменяет sandbox announcement для sandbox-доставки. + + Аргументы: + announcement_id: идентифицирует sandbox-объявление доставки. + date: задает дату события. + options: передает дополнительные параметры операции. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_CANCEL_SANDBOX_ANNOUNCEMENT, + request=SandboxCancelAnnouncementRequest( + announcement_id=announcement_id, + date=serialize_iso_datetime("date", date), + options=options, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/v1/cancelParcel", + spec="Доставка.json", + operation_id="v1CancelParcel", + variant="async", + method_args={"parcel_id": "body.parcel_id"}, + ) + async def cancel_sandbox_parcel( + self, + *, + parcel_id: str, + options: CancelSandboxParcelOptions | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Отменяет sandbox parcel для sandbox-доставки. + + Аргументы: + parcel_id: идентифицирует отправление. + options: передает дополнительные параметры операции. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_CANCEL_SANDBOX_PARCEL, + request=CancelSandboxParcelRequest(parcel_id=parcel_id, options=options), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/v1/changeParcel", + spec="Доставка.json", + operation_id="v1changeParcel", + variant="async", + method_args={"type": "body.type", "parcel_id": "body.parcel_id"}, + ) + async def change_sandbox_parcel( + self, + *, + type: str, + parcel_id: str, + application: ChangeParcelApplication | None = None, + options: ChangeParcelOptions | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Изменяет sandbox parcel для sandbox-доставки. + + Аргументы: + type: передает значение `type` в upstream API. + parcel_id: идентифицирует отправление. + application: передает значение `application` в upstream API. + options: передает дополнительные параметры операции. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_CHANGE_SANDBOX_PARCEL, + request=ChangeParcelRequest( + type=type, + parcel_id=parcel_id, + application=application, + options=options, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/v1/createAnnouncement", + spec="Доставка.json", + operation_id="v1createAnnouncement", + variant="async", + method_args={ + "announcement_id": "body.announcement_id", + "barcode": "body.barcode", + "sender": "body.sender", + "receiver": "body.receiver", + "announcement_type": "body.announcement_type", + "date": "body.date", + "packages": "body.packages", + "options": "body.options", + }, + ) + async def create_sandbox_announcement( + self, + *, + announcement_id: str, + barcode: str, + sender: SandboxAnnouncementParticipant, + receiver: SandboxAnnouncementParticipant, + announcement_type: str, + date: DateInput, + packages: Sequence[SandboxAnnouncementPackage], + options: SandboxCreateAnnouncementOptions, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Создает sandbox announcement для sandbox-доставки. + + Аргументы: + announcement_id: идентифицирует sandbox-объявление доставки. + barcode: передает штрихкод отправления. + sender: передает данные отправителя. + receiver: передает данные получателя. + announcement_type: задает тип sandbox-объявления доставки. + date: задает дату события. + packages: передает грузовые места отправления. + options: передает дополнительные параметры операции. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_CREATE_SANDBOX_ANNOUNCEMENT, + request=SandboxCreateAnnouncementRequest( + announcement_id=announcement_id, + barcode=barcode, + sender=sender, + receiver=receiver, + announcement_type=announcement_type, + date=serialize_iso_datetime("date", date), + packages=list(packages), + options=options, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/v1/getAnnouncementEvent", + spec="Доставка.json", + operation_id="v1getAnnouncementEvent", + variant="async", + method_args={"announcement_id": "body.announcement_id"}, + ) + async def get_sandbox_announcement_event( + self, + *, + announcement_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Возвращает sandbox announcement event для sandbox-доставки. + + Аргументы: + announcement_id: идентифицирует sandbox-объявление доставки. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_GET_ANNOUNCEMENT_EVENT, + request=SandboxGetAnnouncementEventRequest(announcement_id=announcement_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/v1/getChangeParcelInfo", + spec="Доставка.json", + operation_id="v1getChangeParcelInfo", + variant="async", + method_args={"application_id": "body.application_id"}, + ) + async def get_sandbox_change_parcel_info( + self, + *, + application_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Возвращает sandbox change parcel info для sandbox-доставки. + + Аргументы: + application_id: идентифицирует заявку на изменение отправления. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_GET_CHANGE_PARCEL_INFO, + request=GetChangeParcelInfoRequest(application_id=application_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/v1/getParcelInfo", + spec="Доставка.json", + operation_id="v1getParcelInfo", + variant="async", + method_args={"parcel_id": "body.parcel_id"}, + ) + async def get_sandbox_parcel_info( + self, + *, + parcel_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Возвращает sandbox parcel info для sandbox-доставки. + + Аргументы: + parcel_id: идентифицирует отправление. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_GET_PARCEL_INFO, + request=GetSandboxParcelInfoRequest(parcel_id=parcel_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/delivery-sandbox/v1/getRegisteredParcelID", + spec="Доставка.json", + operation_id="v1getRegisteredParcelID", + variant="async", + method_args={"order_id": "body.order_id"}, + ) + async def get_sandbox_registered_parcel_id( + self, + *, + order_id: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryEntityResult: + """Возвращает sandbox registered parcel id для sandbox-доставки. + + Аргументы: + order_id: идентифицирует заказ. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryEntityResult` со статусом выполнения операции. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + SANDBOX_GET_REGISTERED_PARCEL_ID, + request=GetRegisteredParcelIdRequest(order_id=order_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncDeliveryTask(AsyncDomainObject): + """Доменный объект задачи доставки.""" + + __swagger_domain__ = "orders" + __sdk_factory__ = "delivery_task" + __sdk_factory_args__ = {"task_id": "path.task_id"} + + task_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/delivery-sandbox/tasks/{task_id}", + spec="Доставка.json", + operation_id="GetTask", + variant="async", + ) + async def get( + self, + *, + task_id: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> DeliveryTaskInfo: + """Возвращает задач доставки. + + Аргументы: + task_id: идентифицирует асинхронную задачу. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `DeliveryTaskInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_task_id = task_id or self._require_task_id() + return await self._execute( + GET_DELIVERY_TASK, + path_params={"task_id": resolved_task_id}, + timeout=timeout, + retry=retry, + ) + + def _require_task_id(self) -> str: + """Validate required task id.""" + if self.task_id is None: + raise ValidationError("Для операции требуется `task_id`.") + return str(self.task_id) + + +@dataclass(slots=True, frozen=True) +class AsyncStock(AsyncDomainObject): + """Доменный объект управления остатками.""" + + __swagger_domain__ = "orders" + __sdk_factory__ = "stock" + + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/stock-management/1/info", + spec="Управлениеостатками.json", + variant="async", + method_args={"item_ids": "body.item_ids"}, + ) + async def get( + self, + *, + item_ids: Sequence[int], + strong_consistency: bool | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> StockInfoResult: + """Возвращает остатков товаров. + + Аргументы: + item_ids: передает идентификаторы объявлений или товаров. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `StockInfoResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_STOCK_INFO, + request=StockInfoRequest( + item_ids=list(item_ids), + strong_consistency=strong_consistency, + ), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "PUT", + "/stock-management/1/stocks", + spec="Управлениеостатками.json", + variant="async", + method_args={"stocks": "body.stocks"}, + ) + async def update( + self, + *, + stocks: Sequence[StockUpdateEntry], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> StockUpdateResult: + """Обновляет остатки товаров. + + Аргументы: + stocks: передает остатки товаров для обновления. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `StockUpdateResult` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPDATE_STOCKS, + request=StockUpdateRequest(stocks=list(stocks)), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + +__all__ = ( + "AsyncDeliveryOrder", + "AsyncDeliveryTask", + "AsyncOrder", + "AsyncOrderLabel", + "AsyncSandboxDelivery", + "AsyncStock", +) diff --git a/avito/promotion/__init__.py b/avito/promotion/__init__.py index 6c14cbe..2e393dc 100644 --- a/avito/promotion/__init__.py +++ b/avito/promotion/__init__.py @@ -1,5 +1,13 @@ """Пакет promotion.""" +from avito.promotion.async_domain import ( + AsyncAutostrategyCampaign, + AsyncBbipPromotion, + AsyncCpaAuction, + AsyncPromotionOrder, + AsyncTargetActionPricing, + AsyncTrxPromotion, +) from avito.promotion.domain import ( AutostrategyCampaign, BbipPromotion, @@ -63,6 +71,12 @@ ) __all__ = ( + "AsyncAutostrategyCampaign", + "AsyncBbipPromotion", + "AsyncCpaAuction", + "AsyncPromotionOrder", + "AsyncTargetActionPricing", + "AsyncTrxPromotion", "AutostrategyBudget", "AutostrategyCampaign", "AutostrategyStat", diff --git a/avito/promotion/async_domain.py b/avito/promotion/async_domain.py new file mode 100644 index 0000000..735c45a --- /dev/null +++ b/avito/promotion/async_domain.py @@ -0,0 +1,1467 @@ +"""Async-доменные объекты пакета promotion.""" + +from __future__ import annotations + +import builtins +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime + +from avito.core import ApiTimeouts, RetryOverride, ValidationError +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation +from avito.core.validation import ( + validate_non_empty, + validate_non_empty_string, + validate_positive_int, +) +from avito.promotion.models import ( + AutostrategyBudget, + AutostrategyStat, + BbipForecastsResult, + BbipItem, + BbipSuggestsResult, + CampaignActionResult, + CampaignDetailsResult, + CampaignListFilter, + CampaignOrderBy, + CampaignsResult, + CampaignType, + CampaignUpdateTimeFilter, + CancelTrxPromotionRequest, + CpaAuctionBidInput, + CpaAuctionBidsResult, + CreateAutostrategyBudgetRequest, + CreateAutostrategyCampaignRequest, + CreateBbipForecastsRequest, + CreateBbipOrderRequest, + CreateBbipSuggestsRequest, + CreateItemBid, + CreateItemBidsRequest, + CreateTrxPromotionApplyRequest, + DeletePromotionRequest, + GetAutostrategyCampaignInfoRequest, + GetAutostrategyStatRequest, + GetPromotionOrderStatusRequest, + GetPromotionsByItemIdsRequest, + GetTrxCommissionsRequest, + ListAutostrategyCampaignsRequest, + ListPromotionOrdersRequest, + ListPromotionServicesRequest, + PromotionActionResult, + PromotionOrdersResult, + PromotionOrderStatusResult, + PromotionServiceDictionary, + PromotionServicesResult, + PromotionStatus, + StopAutostrategyCampaignRequest, + TargetActionBudgetType, + TargetActionGetBidsResult, + TargetActionPromotionsByItemIdsResult, + TrxCommissionsResult, + TrxItem, + UpdateAutoBidRequest, + UpdateAutostrategyCampaignRequest, + UpdateManualBidRequest, +) +from avito.promotion.operations import ( + APPLY_TRX, + CANCEL_TRX, + CREATE_AUTOSTRATEGY_BUDGET, + CREATE_AUTOSTRATEGY_CAMPAIGN, + CREATE_BBIP_ORDER, + CREATE_CPA_AUCTION_BIDS, + DELETE_AUTOSTRATEGY_CAMPAIGN, + DELETE_TARGET_ACTION_PROMOTION, + GET_AUTOSTRATEGY_CAMPAIGN, + GET_AUTOSTRATEGY_STAT, + GET_BBIP_FORECASTS, + GET_BBIP_SUGGESTS, + GET_CPA_AUCTION_BIDS, + GET_ORDER_STATUS, + GET_SERVICE_DICTIONARY, + GET_TARGET_ACTION_BIDS, + GET_TARGET_ACTION_PROMOTIONS, + GET_TRX_COMMISSIONS, + LIST_AUTOSTRATEGY_CAMPAIGNS, + LIST_ORDERS, + LIST_SERVICES, + TRX_HEADERS, + UPDATE_AUTOSTRATEGY_CAMPAIGN, + UPDATE_TARGET_ACTION_AUTO, + UPDATE_TARGET_ACTION_MANUAL, +) + + +def _preview_result( + *, + action: str, + target: Mapping[str, object], + request_payload: Mapping[str, object], +) -> PromotionActionResult: + """Build result.""" + return PromotionActionResult( + action=action, + target=dict(target), + status=PromotionStatus.PREVIEW, + applied=False, + request_payload=dict(request_payload), + details={"validated": True}, + ) + + +def _validate_optional_datetime(name: str, value: datetime | None) -> None: + """Validate optional datetime.""" + if value is not None and not isinstance(value, datetime): + raise ValidationError(f"`{name}` должен быть datetime.") + + +@dataclass(slots=True, frozen=True) +class AsyncPromotionOrder(AsyncDomainObject): + """Доменный объект заявок и словарей promotion API.""" + + __swagger_domain__ = "promotion" + __sdk_factory__ = "promotion_order" + __sdk_factory_args__ = {"order_id": "path.order_id"} + + order_id: int | str | None = None + + @swagger_operation( + "POST", + "/promotion/v1/items/services/dict", + spec="Продвижение.json", + operation_id="get_dict_of_services_v1", + variant="async", + ) + async def get_service_dictionary( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> PromotionServiceDictionary: + """Получает словарь услуг продвижения. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionServiceDictionary` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_SERVICE_DICTIONARY, timeout=timeout, retry=retry) + + @swagger_operation( + "POST", + "/promotion/v1/items/services/get", + spec="Продвижение.json", + operation_id="get_services_by_items_v1", + method_args={"item_ids": "body.item_ids"}, + variant="async", + ) + async def list_services( + self, + *, + item_ids: list[int], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionServicesResult: + """Возвращает доступные услуги продвижения для объявлений. + + Аргументы: + item_ids: передает идентификаторы объявлений или товаров. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionServicesResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + LIST_SERVICES, + request=ListPromotionServicesRequest(item_ids=item_ids), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/promotion/v1/items/services/orders/get", + spec="Продвижение.json", + operation_id="list_orders_by_user_v1", + variant="async", + ) + async def list_orders( + self, + *, + item_ids: list[int] | None = None, + order_ids: list[str] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionOrdersResult: + """Возвращает заказы продвижения по объявлениям или идентификаторам заказов. + + Аргументы: + item_ids: передает идентификаторы объявлений или товаров. + order_ids: передает идентификаторы заказов. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionOrdersResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + LIST_ORDERS, + request=ListPromotionOrdersRequest(item_ids=item_ids, order_ids=order_ids), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/promotion/v1/items/services/orders/status", + spec="Продвижение.json", + operation_id="get_order_status_v1", + variant="async", + ) + async def get_order_status( + self, + *, + order_ids: list[str] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionOrderStatusResult: + """Получает статусы заявок на продвижение. + + Аргументы: + order_ids: идентификаторы заказов продвижения. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionOrderStatusResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_order_ids = order_ids or ( + [str(self.order_id)] if self.order_id is not None else [] + ) + if not resolved_order_ids: + raise ValidationError("Для операции требуется хотя бы один `order_id`.") + return await self._execute( + GET_ORDER_STATUS, + request=GetPromotionOrderStatusRequest(order_ids=resolved_order_ids), + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncBbipPromotion(AsyncDomainObject): + """Доменный объект BBIP-продвижения.""" + + __swagger_domain__ = "promotion" + __sdk_factory__ = "bbip_promotion" + __sdk_factory_args__ = {"item_id": "path.item_id"} + + item_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/promotion/v1/items/services/bbip/forecasts/get", + spec="Продвижение.json", + operation_id="get_bbip_forecasts_by_items_v1", + method_args={"items": "body.items"}, + variant="async", + ) + async def get_forecasts( + self, + *, + items: list[BbipItem], + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> BbipForecastsResult: + """Получает прогнозы BBIP. + + Аргументы: + items: элементы запроса с объявлениями, ставками или настройками продвижения. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `BbipForecastsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_BBIP_FORECASTS, + request=CreateBbipForecastsRequest(items=list(items)), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "PUT", + "/promotion/v1/items/services/bbip/orders/create", + spec="Продвижение.json", + operation_id="create_bbip_order_for_items_v1", + method_args={"items": "body.items"}, + variant="async", + ) + async def create_order( + self, + *, + items: list[BbipItem], + dry_run: bool = False, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionActionResult: + """Подключает BBIP-продвижение. + + Аргументы: + items: элементы запроса с объявлениями, ставками или настройками продвижения. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + validate_non_empty("items", items) + for index, item in enumerate(items): + validate_positive_int(f"items[{index}].item_id", item.item_id) + validate_positive_int(f"items[{index}].duration", item.duration) + validate_positive_int(f"items[{index}].price", item.price) + validate_positive_int(f"items[{index}].old_price", item.old_price) + bbip_items = list(items) + request_payload = CreateBbipOrderRequest(items=bbip_items).to_payload() + target: dict[str, object] = {"item_ids": [item.item_id for item in items]} + if dry_run: + return _preview_result( + action="create_order", + target=target, + request_payload=request_payload, + ) + payload = await self._execute( + CREATE_BBIP_ORDER, + request=CreateBbipOrderRequest(items=bbip_items), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="create_order", + target=target, + request_payload=request_payload, + ) + + @swagger_operation( + "POST", + "/promotion/v1/items/services/bbip/suggests/get", + spec="Продвижение.json", + operation_id="get_bbip_suggests_by_items_v1", + variant="async", + ) + async def get_suggests( + self, + *, + item_ids: list[int] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> BbipSuggestsResult: + """Получает варианты бюджета BBIP. + + Аргументы: + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `BbipSuggestsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_item_ids = item_ids or self._resource_item_ids() + return await self._execute( + GET_BBIP_SUGGESTS, + request=CreateBbipSuggestsRequest(item_ids=resolved_item_ids), + timeout=timeout, + retry=retry, + ) + + def _resource_item_ids(self) -> list[int]: + """Run the resource item ids helper.""" + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id` или список `item_ids`.") + return [int(self.item_id)] + + +@dataclass(slots=True, frozen=True) +class AsyncTrxPromotion(AsyncDomainObject): + """Доменный объект TrxPromo.""" + + __swagger_domain__ = "promotion" + __sdk_factory__ = "trx_promotion" + __sdk_factory_args__ = {"item_id": "path.item_id"} + + item_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/trx-promo/1/apply", + spec="TrxPromo.json", + operation_id="api_trx_promo_open_api_apply", + method_args={"items": "body.items"}, + variant="async", + ) + async def apply( + self, + *, + items: list[TrxItem], + dry_run: bool = False, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionActionResult: + """Запускает TrxPromo. + + Аргументы: + items: элементы запроса с объявлениями, ставками или настройками продвижения. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + validate_non_empty("items", items) + for index, item in enumerate(items): + validate_positive_int(f"items[{index}].item_id", item.item_id) + validate_positive_int(f"items[{index}].commission", item.commission) + if not isinstance(item.date_from, datetime): + raise ValidationError(f"items[{index}].date_from должен быть datetime.") + trx_items = list(items) + request_payload = CreateTrxPromotionApplyRequest(items=trx_items).to_payload() + target: dict[str, object] = {"item_ids": [item.item_id for item in items]} + if dry_run: + return _preview_result(action="apply", target=target, request_payload=request_payload) + payload = await self._execute( + APPLY_TRX, + request=CreateTrxPromotionApplyRequest(items=trx_items), + headers=TRX_HEADERS, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="apply", + target=target, + request_payload=request_payload, + ) + + @swagger_operation( + "POST", + "/trx-promo/1/cancel", + spec="TrxPromo.json", + operation_id="api_trx_promo_open_api_cancel", + method_args={"item_ids": "body.itemIDs"}, + variant="async", + ) + async def delete( + self, + *, + item_ids: list[int] | None = None, + dry_run: bool = False, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionActionResult: + """Останавливает TrxPromo. + + Аргументы: + item_ids: список идентификаторов объявлений. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_item_ids = item_ids or self._resource_item_ids() + validate_non_empty("item_ids", resolved_item_ids) + request_payload = CancelTrxPromotionRequest(item_ids=resolved_item_ids).to_payload() + target = {"item_ids": list(resolved_item_ids)} + if dry_run: + return _preview_result(action="delete", target=target, request_payload=request_payload) + payload = await self._execute( + CANCEL_TRX, + request=CancelTrxPromotionRequest(item_ids=resolved_item_ids), + headers=TRX_HEADERS, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="delete", + target=target, + request_payload=request_payload, + ) + + @swagger_operation( + "GET", + "/trx-promo/1/commissions", + spec="TrxPromo.json", + operation_id="api_trx_promo_open_api_commissions", + method_args={"item_ids": "body.item_ids"}, + variant="async", + ) + async def get_commissions( + self, + *, + item_ids: list[int] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> TrxCommissionsResult: + """Получает доступные комиссии TrxPromo. + + Аргументы: + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `TrxCommissionsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_item_ids = item_ids or self._resource_item_ids() + return await self._execute( + GET_TRX_COMMISSIONS, + request=GetTrxCommissionsRequest(item_ids=resolved_item_ids), + headers=TRX_HEADERS, + timeout=timeout, + retry=retry, + ) + + def _resource_item_ids(self) -> list[int]: + """Run the resource item ids helper.""" + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id` или список `item_ids`.") + return [int(self.item_id)] + + +@dataclass(slots=True, frozen=True) +class AsyncCpaAuction(AsyncDomainObject): + """Доменный объект CPA-аукциона.""" + + __swagger_domain__ = "promotion" + __sdk_factory__ = "cpa_auction" + __sdk_factory_args__ = {"item_id": "path.item_id"} + + item_id: int | str | None = None + + @swagger_operation( + "GET", + "/auction/1/bids", + spec="CPA-аукцион.json", + operation_id="getUserBids", + variant="async", + ) + async def get_user_bids( + self, + *, + from_item_id: int | None = None, + batch_size: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CpaAuctionBidsResult: + """Получает действующие и доступные ставки. + + Аргументы: + from_item_id: идентификатор объявления, с которого начинается выборка. + batch_size: размер пакетной выборки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CpaAuctionBidsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_CPA_AUCTION_BIDS, + query={"fromItemID": from_item_id, "batchSize": batch_size}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/auction/1/bids", + spec="CPA-аукцион.json", + operation_id="saveItemBids", + method_args={"items": "body.items"}, + variant="async", + ) + async def create_item_bids( + self, + *, + items: list[CpaAuctionBidInput], + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionActionResult: + """Сохраняет новые ставки по объявлениям. + + Аргументы: + items: элементы запроса с объявлениями, ставками или настройками продвижения. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + bids = [CreateItemBid(item_id=item.item_id, price_penny=item.price_penny) for item in items] + request = CreateItemBidsRequest(items=bids) + payload = await self._execute( + CREATE_CPA_AUCTION_BIDS, + request=request, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="create_item_bids", + target={"item_ids": [item.item_id for item in items]}, + request_payload=request.to_payload(), + ) + + +@dataclass(slots=True, frozen=True) +class AsyncTargetActionPricing(AsyncDomainObject): + """Доменный объект цены целевого действия.""" + + __swagger_domain__ = "promotion" + __sdk_factory__ = "target_action_pricing" + __sdk_factory_args__ = {"item_id": "path.item_id"} + + item_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/cpxpromo/1/getBids/{itemId}", + spec="Настройкаценыцелевогодействия.json", + operation_id="getBids", + variant="async", + ) + async def get_bids( + self, + *, + item_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> TargetActionGetBidsResult: + """Получает детализированные цены и бюджеты. + + Аргументы: + item_id: идентификатор объявления. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `TargetActionGetBidsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_TARGET_ACTION_BIDS, + path_params={"itemId": item_id or self._require_item_id()}, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/cpxpromo/1/getPromotionsByItemIds", + spec="Настройкаценыцелевогодействия.json", + operation_id="getPromotionsByItemIds", + variant="async", + ) + async def get_promotions_by_item_ids( + self, + *, + item_ids: list[int] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> TargetActionPromotionsByItemIdsResult: + """Получает текущие настройки по нескольким объявлениям. + + Аргументы: + item_ids: список идентификаторов объявлений. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `TargetActionPromotionsByItemIdsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_item_ids = item_ids or [self._require_item_id()] + return await self._execute( + GET_TARGET_ACTION_PROMOTIONS, + request=GetPromotionsByItemIdsRequest(item_ids=resolved_item_ids), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/cpxpromo/1/remove", + spec="Настройкаценыцелевогодействия.json", + operation_id="removePromotion", + variant="async", + ) + async def delete( + self, + *, + item_id: int | None = None, + dry_run: bool = False, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionActionResult: + """Останавливает продвижение. + + Аргументы: + item_id: идентификатор объявления. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_item_id = item_id or self._require_item_id() + validate_positive_int("item_id", resolved_item_id) + request_payload = DeletePromotionRequest(item_id=resolved_item_id).to_payload() + target = {"item_id": resolved_item_id} + if dry_run: + return _preview_result(action="delete", target=target, request_payload=request_payload) + payload = await self._execute( + DELETE_TARGET_ACTION_PROMOTION, + request=DeletePromotionRequest(item_id=resolved_item_id), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="delete", + target=target, + request_payload=request_payload, + ) + + @swagger_operation( + "POST", + "/cpxpromo/1/setAuto", + spec="Настройкаценыцелевогодействия.json", + operation_id="saveAutoBid", + method_args={ + "action_type_id": "body.action_type_id", + "budget_penny": "body.budget_penny", + "budget_type": "body.budget_type", + }, + variant="async", + ) + async def update_auto( + self, + *, + action_type_id: int, + budget_penny: int, + budget_type: TargetActionBudgetType | str, + item_id: int | None = None, + dry_run: bool = False, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionActionResult: + """Применяет автоматическую настройку. + + Аргументы: + action_type_id: идентификатор целевого действия. + budget_penny: бюджет в копейках. + budget_type: тип бюджета кампании. + item_id: идентификатор объявления. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_item_id = item_id or self._require_item_id() + validate_positive_int("item_id", resolved_item_id) + validate_positive_int("action_type_id", action_type_id) + validate_positive_int("budget_penny", budget_penny) + validate_non_empty_string("budget_type", budget_type) + request_payload = UpdateAutoBidRequest( + item_id=resolved_item_id, + action_type_id=action_type_id, + budget_penny=budget_penny, + budget_type=budget_type, + ).to_payload() + target = {"item_id": resolved_item_id} + if dry_run: + return _preview_result( + action="update_auto", + target=target, + request_payload=request_payload, + ) + payload = await self._execute( + UPDATE_TARGET_ACTION_AUTO, + request=UpdateAutoBidRequest( + item_id=resolved_item_id, + action_type_id=action_type_id, + budget_penny=budget_penny, + budget_type=budget_type, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="update_auto", + target=target, + request_payload=request_payload, + ) + + @swagger_operation( + "POST", + "/cpxpromo/1/setManual", + spec="Настройкаценыцелевогодействия.json", + operation_id="saveManualBid", + method_args={"action_type_id": "body.action_type_id", "bid_penny": "body.bid_penny"}, + variant="async", + ) + async def update_manual( + self, + *, + action_type_id: int, + bid_penny: int, + limit_penny: int | None = None, + item_id: int | None = None, + dry_run: bool = False, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> PromotionActionResult: + """Применяет ручную настройку. + + Аргументы: + action_type_id: идентификатор целевого действия. + bid_penny: ставка в копейках. + limit_penny: лимит расходов в копейках. + item_id: идентификатор объявления. + dry_run: если `True`, метод собирает payload и возвращает результат без вызова транспорта. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `PromotionActionResult` с типизированными данными ответа. + + Поведение: + При `dry_run=True` payload строится без вызова транспорта. + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_item_id = item_id or self._require_item_id() + validate_positive_int("item_id", resolved_item_id) + validate_positive_int("action_type_id", action_type_id) + validate_positive_int("bid_penny", bid_penny) + if limit_penny is not None: + validate_positive_int("limit_penny", limit_penny) + request_payload = UpdateManualBidRequest( + item_id=resolved_item_id, + action_type_id=action_type_id, + bid_penny=bid_penny, + limit_penny=limit_penny, + ).to_payload() + target = {"item_id": resolved_item_id} + if dry_run: + return _preview_result( + action="update_manual", + target=target, + request_payload=request_payload, + ) + payload = await self._execute( + UPDATE_TARGET_ACTION_MANUAL, + request=UpdateManualBidRequest( + item_id=resolved_item_id, + action_type_id=action_type_id, + bid_penny=bid_penny, + limit_penny=limit_penny, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + return PromotionActionResult.from_action_payload( + payload, + action="update_manual", + target=target, + request_payload=request_payload, + ) + + def _require_item_id(self) -> int: + """Validate required item id.""" + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return int(self.item_id) + + +@dataclass(slots=True, frozen=True) +class AsyncAutostrategyCampaign(AsyncDomainObject): + """Доменный объект кампаний автостратегии.""" + + __swagger_domain__ = "promotion" + __sdk_factory__ = "autostrategy_campaign" + __sdk_factory_args__ = {"campaign_id": "path.campaign_id"} + + campaign_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/autostrategy/v1/budget", + spec="Автостратегия.json", + operation_id="getAutostrategyBudget", + method_args={"campaign_type": "body.campaign_type"}, + variant="async", + ) + async def create_budget( + self, + *, + campaign_type: CampaignType | str, + start_time: datetime | None = None, + finish_time: datetime | None = None, + items: list[int] | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutostrategyBudget: + """Рассчитывает бюджет кампании. + + Аргументы: + campaign_type: тип автостратегии или рекламной кампании. + start_time: дата и время начала кампании. + finish_time: дата и время окончания кампании. + items: элементы запроса с объявлениями, ставками или настройками продвижения. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutostrategyBudget` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + _validate_optional_datetime("start_time", start_time) + _validate_optional_datetime("finish_time", finish_time) + return await self._execute( + CREATE_AUTOSTRATEGY_BUDGET, + request=CreateAutostrategyBudgetRequest( + campaign_type=campaign_type, + start_time=start_time, + finish_time=finish_time, + items=items, + ), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autostrategy/v1/campaign/create", + spec="Автостратегия.json", + operation_id="createAutostrategyCampaign", + method_args={"campaign_type": "body.campaign_type", "title": "body.title"}, + variant="async", + ) + async def create( + self, + *, + campaign_type: CampaignType | str, + title: str, + budget: int | None = None, + budget_bonus: int | None = None, + budget_real: int | None = None, + calc_id: int | None = None, + description: str | None = None, + finish_time: datetime | None = None, + items: list[int] | None = None, + start_time: datetime | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CampaignActionResult: + """Создает новую кампанию. + + Аргументы: + campaign_type: тип автостратегии или рекламной кампании. + title: название кампании. + budget: бюджет кампании. + budget_bonus: бонусный бюджет кампании. + budget_real: реальный бюджет кампании. + calc_id: идентификатор расчета или прогноза кампании. + description: описание кампании. + finish_time: дата и время окончания кампании. + items: элементы запроса с объявлениями, ставками или настройками продвижения. + start_time: дата и время начала кампании. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CampaignActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + _validate_optional_datetime("start_time", start_time) + _validate_optional_datetime("finish_time", finish_time) + return await self._execute( + CREATE_AUTOSTRATEGY_CAMPAIGN, + request=CreateAutostrategyCampaignRequest( + campaign_type=campaign_type, + title=title, + budget=budget, + budget_bonus=budget_bonus, + budget_real=budget_real, + calc_id=calc_id, + description=description, + finish_time=finish_time, + items=items, + start_time=start_time, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autostrategy/v1/campaign/edit", + spec="Автостратегия.json", + operation_id="editAutostrategyCampaign", + method_args={"campaign_id": "body.campaignId", "version": "body.version"}, + variant="async", + ) + async def update( + self, + *, + version: int, + campaign_id: int | None = None, + budget: int | None = None, + calc_id: int | None = None, + description: str | None = None, + finish_time: datetime | None = None, + items: list[int] | None = None, + start_time: datetime | None = None, + title: str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CampaignActionResult: + """Редактирует кампанию. + + Аргументы: + version: версия кампании для optimistic locking или согласованного обновления. + campaign_id: идентификатор кампании. + budget: бюджет кампании. + calc_id: идентификатор расчета или прогноза кампании. + description: описание кампании. + finish_time: дата и время окончания кампании. + items: элементы запроса с объявлениями, ставками или настройками продвижения. + start_time: дата и время начала кампании. + title: название кампании. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CampaignActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + _validate_optional_datetime("start_time", start_time) + _validate_optional_datetime("finish_time", finish_time) + return await self._execute( + UPDATE_AUTOSTRATEGY_CAMPAIGN, + request=UpdateAutostrategyCampaignRequest( + campaign_id=campaign_id or self._require_campaign_id(), + version=version, + budget=budget, + calc_id=calc_id, + description=description, + finish_time=finish_time, + items=items, + start_time=start_time, + title=title, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autostrategy/v1/campaign/info", + spec="Автостратегия.json", + operation_id="getAutostrategyCampaignInfo", + method_args={"campaign_id": "body.campaign_id"}, + variant="async", + ) + async def get( + self, + *, + campaign_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CampaignDetailsResult: + """Получает полную информацию о кампании. + + Аргументы: + campaign_id: идентификатор кампании. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CampaignDetailsResult` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_AUTOSTRATEGY_CAMPAIGN, + request=GetAutostrategyCampaignInfoRequest( + campaign_id=campaign_id or self._require_campaign_id() + ), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autostrategy/v1/campaign/stop", + spec="Автостратегия.json", + operation_id="stopAutostrategyCampaign", + method_args={"campaign_id": "body.campaignId", "version": "body.version"}, + variant="async", + ) + async def delete( + self, + *, + version: int, + campaign_id: int | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CampaignActionResult: + """Останавливает кампанию. + + Аргументы: + version: версия кампании для optimistic locking или согласованного обновления. + campaign_id: идентификатор кампании. + idempotency_key: ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CampaignActionResult` с типизированными данными ответа. + + Поведение: + `idempotency_key` передается в `Idempotency-Key` и должен быть стабильным для одного логического write-вызова. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + DELETE_AUTOSTRATEGY_CAMPAIGN, + request=StopAutostrategyCampaignRequest( + campaign_id=campaign_id or self._require_campaign_id(), + version=version, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autostrategy/v1/campaigns", + spec="Автостратегия.json", + operation_id="getAutostrategyCampaigns", + variant="async", + ) + async def list( + self, + *, + limit: int = 100, + offset: int | None = None, + status_id: builtins.list[int] | None = None, + order_by: builtins.list[tuple[str, str]] | None = None, + updated_from: datetime | None = None, + updated_to: datetime | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> CampaignsResult: + """Возвращает кампании автостратегии с фильтрами и пагинацией. + + Аргументы: + limit: ограничивает размер возвращаемой выборки. + offset: задает смещение первой записи в выборке. + status_id: фильтрует результат по числовому статусу. + order_by: задает порядок сортировки результата. + updated_from: фильтрует записи, обновленные не раньше указанного времени. + updated_to: фильтрует записи, обновленные не позже указанного времени. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `CampaignsResult` с типизированными данными ответа API. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + filter_payload = ( + CampaignListFilter( + by_update_time=CampaignUpdateTimeFilter( + from_time=updated_from, + to_time=updated_to, + ) + ) + if updated_from is not None or updated_to is not None + else None + ) + order_by_payload = ( + [CampaignOrderBy(column=column, direction=direction) for column, direction in order_by] + if order_by is not None + else None + ) + return await self._execute( + LIST_AUTOSTRATEGY_CAMPAIGNS, + request=ListAutostrategyCampaignsRequest( + limit=limit, + offset=offset, + status_id=status_id, + order_by=order_by_payload, + filter=filter_payload, + ), + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/autostrategy/v1/stat", + spec="Автостратегия.json", + operation_id="getAutostrategyStat", + variant="async", + ) + async def get_stat( + self, + *, + campaign_id: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> AutostrategyStat: + """Получает статистику кампании. + + Аргументы: + campaign_id: идентификатор кампании. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `AutostrategyStat` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_AUTOSTRATEGY_STAT, + request=GetAutostrategyStatRequest( + campaign_id=campaign_id or self._require_campaign_id() + ), + timeout=timeout, + retry=retry, + ) + + def _require_campaign_id(self) -> int: + """Validate required campaign id.""" + if self.campaign_id is None: + raise ValidationError("Для операции требуется `campaign_id`.") + return int(self.campaign_id) + + +__all__ = ( + "AsyncAutostrategyCampaign", + "AsyncBbipPromotion", + "AsyncCpaAuction", + "AsyncPromotionOrder", + "AsyncTargetActionPricing", + "AsyncTrxPromotion", +) diff --git a/avito/ratings/__init__.py b/avito/ratings/__init__.py index 9bf5554..27c9045 100644 --- a/avito/ratings/__init__.py +++ b/avito/ratings/__init__.py @@ -1,5 +1,6 @@ """Пакет ratings.""" +from avito.ratings.async_domain import AsyncRatingProfile, AsyncReview, AsyncReviewAnswer from avito.ratings.domain import RatingProfile, Review, ReviewAnswer from avito.ratings.models import ( RatingProfileInfo, @@ -11,6 +12,9 @@ ) __all__ = ( + "AsyncRatingProfile", + "AsyncReview", + "AsyncReviewAnswer", "RatingProfile", "RatingProfileInfo", "Review", diff --git a/avito/ratings/async_domain.py b/avito/ratings/async_domain.py new file mode 100644 index 0000000..09a6a4e --- /dev/null +++ b/avito/ratings/async_domain.py @@ -0,0 +1,226 @@ +"""Async-доменные объекты пакета ratings.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from avito.core import ApiTimeouts, RetryOverride, ValidationError +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation +from avito.ratings.models import ( + CreateReviewAnswerRequest, + RatingProfileInfo, + ReviewAnswerInfo, + ReviewsQuery, + ReviewsResult, +) +from avito.ratings.operations import ( + CREATE_REVIEW_ANSWER, + DELETE_REVIEW_ANSWER, + GET_RATINGS_INFO, + LIST_REVIEWS, +) + + +@dataclass(slots=True, frozen=True) +class AsyncReview(AsyncDomainObject): + """Async-доменный объект отзывов.""" + + __swagger_domain__ = "ratings" + __sdk_factory__ = "review" + + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/ratings/v1/reviews", + spec="Рейтингииотзывы.json", + operation_id="getReviewsV1", + variant="async", + ) + async def list( + self, + *, + offset: int | None = None, + page: int | None = None, + limit: int | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ReviewsResult: + """Возвращает список отзывов асинхронно. + + Аргументы: + offset: задает смещение первой записи в выборке. + page: задает номер страницы для постраничной выборки. + limit: ограничивает размер возвращаемой выборки. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ReviewsResult` с типизированными данными ответа API. + + Поведение: + Параметры пагинации ограничивают объем данных без изменения модели ответа. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + resolved_query = ReviewsQuery( + offset=offset if offset is not None else 0, + page=page if page is not None else 1, + limit=limit if limit is not None else 50, + ) + return await self._execute( + LIST_REVIEWS, + query=resolved_query, + timeout=timeout, + retry=retry, + ) + + +@dataclass(slots=True, frozen=True) +class AsyncReviewAnswer(AsyncDomainObject): + """Async-доменный объект ответов на отзывы.""" + + __swagger_domain__ = "ratings" + __sdk_factory__ = "review_answer" + __sdk_factory_args__ = {"answer_id": "path.answer_id"} + + answer_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/ratings/v1/answers", + spec="Рейтингииотзывы.json", + operation_id="createReviewAnswerV1", + method_args={"review_id": "body.review_id", "text": "body.message"}, + variant="async", + ) + async def create( + self, + *, + review_id: int, + text: str, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ReviewAnswerInfo: + """Создает ответ на отзыв асинхронно. + + Аргументы: + review_id: идентифицирует отзыв. + text: передает текст ответа или сообщения. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ReviewAnswerInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + CREATE_REVIEW_ANSWER, + request=CreateReviewAnswerRequest(review_id=review_id, text=text), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "DELETE", + "/ratings/v1/answers/{answer_id}", + spec="Рейтингииотзывы.json", + operation_id="removeReviewAnswerV1", + variant="async", + ) + async def delete( + self, + *, + answer_id: int | str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> ReviewAnswerInfo: + """Удаляет ответ на отзыв асинхронно. + + Аргументы: + answer_id: идентифицирует ответ на отзыв. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `ReviewAnswerInfo` с типизированными данными ответа API. + + Поведение: + `idempotency_key` следует передавать для write-операций, которые могут безопасно повторяться. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + DELETE_REVIEW_ANSWER, + path_params={"answer_id": answer_id or self._require_answer_id()}, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + def _require_answer_id(self) -> str: + """Validate required answer id.""" + if self.answer_id is None: + raise ValidationError("Для операции требуется `answer_id`.") + return str(self.answer_id) + + +@dataclass(slots=True, frozen=True) +class AsyncRatingProfile(AsyncDomainObject): + """Async-доменный объект рейтингового профиля.""" + + __swagger_domain__ = "ratings" + __sdk_factory__ = "rating_profile" + + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/ratings/v1/info", + spec="Рейтингииотзывы.json", + operation_id="getRatingsInfoV1", + variant="async", + ) + async def get( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> RatingProfileInfo: + """Возвращает рейтинг профиля асинхронно. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RatingProfileInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_RATINGS_INFO, timeout=timeout, retry=retry) + + +__all__ = ("AsyncRatingProfile", "AsyncReview", "AsyncReviewAnswer") diff --git a/avito/realty/__init__.py b/avito/realty/__init__.py index 36409da..dc64f23 100644 --- a/avito/realty/__init__.py +++ b/avito/realty/__init__.py @@ -1,5 +1,11 @@ """Пакет realty.""" +from avito.realty.async_domain import ( + AsyncRealtyAnalyticsReport, + AsyncRealtyBooking, + AsyncRealtyListing, + AsyncRealtyPricing, +) from avito.realty.domain import ( RealtyAnalyticsReport, RealtyBooking, @@ -25,6 +31,10 @@ ) __all__ = ( + "AsyncRealtyAnalyticsReport", + "AsyncRealtyBooking", + "AsyncRealtyListing", + "AsyncRealtyPricing", "RealtyActionResult", "RealtyAnalyticsInfo", "RealtyAnalyticsReport", diff --git a/avito/realty/async_domain.py b/avito/realty/async_domain.py new file mode 100644 index 0000000..0230515 --- /dev/null +++ b/avito/realty/async_domain.py @@ -0,0 +1,466 @@ +"""Async-доменные объекты пакета realty.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from avito.core import ApiTimeouts, RetryOverride, ValidationError +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation +from avito.core.validation import DateInput, serialize_iso_date +from avito.realty.models import ( + RealtyActionResult, + RealtyAnalyticsInfo, + RealtyBaseParamsUpdateRequest, + RealtyBookingsQuery, + RealtyBookingsResult, + RealtyBookingsUpdateRequest, + RealtyInterval, + RealtyIntervalsRequest, + RealtyMarketPriceInfo, + RealtyPricePeriod, + RealtyPricesUpdateRequest, +) +from avito.realty.operations import ( + GET_INTERVALS, + GET_MARKET_PRICE_CORRESPONDENCE, + GET_REPORT_FOR_CLASSIFIED, + LIST_REALTY_BOOKINGS, + UPDATE_BASE_PARAMS, + UPDATE_BOOKINGS_INFO, + UPDATE_REALTY_PRICES, +) + + +@dataclass(slots=True, frozen=True) +class AsyncRealtyListing(AsyncDomainObject): + """Async-доменный объект объявления краткосрочной аренды.""" + + __swagger_domain__ = "realty" + __sdk_factory__ = "realty_listing" + __sdk_factory_args__ = {"item_id": "path.item_id", "user_id": "path.user_id"} + + item_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/realty/v1/items/intervals", + spec="Краткосрочнаяаренда.json", + operation_id="putIntervals", + method_args={"intervals": "body.intervals", "item_id": "body.item_id"}, + variant="async", + ) + async def get_intervals( + self, + *, + intervals: list[RealtyInterval], + item_id: int | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> RealtyActionResult: + """Возвращает intervals для посутчной аренды асинхронно. + + Аргументы: + intervals: передает интервалы доступности объявления. + item_id: идентифицирует объявление Авито. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RealtyActionResult` со статусом выполнения операции. + + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_INTERVALS, + request=RealtyIntervalsRequest( + item_id=item_id or int(self._require_item_id()), + intervals=intervals, + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/realty/v1/items/{item_id}/base", + spec="Краткосрочнаяаренда.json", + operation_id="postBaseParams", + method_args={"min_stay_days": "body.minimal_duration"}, + variant="async", + ) + async def update_base_params( + self, + *, + min_stay_days: int, + item_id: int | str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> RealtyActionResult: + """Обновляет base params для посутчной аренды асинхронно. + + Аргументы: + min_stay_days: задает минимальное число дней проживания. + item_id: идентифицирует объявление Авито. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RealtyActionResult` со статусом выполнения операции. + + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPDATE_BASE_PARAMS, + path_params={"item_id": item_id or self._require_item_id()}, + request=RealtyBaseParamsUpdateRequest(min_stay_days=min_stay_days), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + def _require_item_id(self) -> str: + """Validate required item id.""" + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return str(self.item_id) + + +@dataclass(slots=True, frozen=True) +class AsyncRealtyBooking(AsyncDomainObject): + """Async-доменный объект бронирований недвижимости.""" + + __swagger_domain__ = "realty" + __sdk_factory__ = "realty_booking" + __sdk_factory_args__ = {"item_id": "path.item_id", "user_id": "path.user_id"} + + item_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/core/v1/accounts/{user_id}/items/{item_id}/bookings", + spec="Краткосрочнаяаренда.json", + operation_id="putBookingsInfo", + method_args={"blocked_dates": "body.bookings"}, + variant="async", + ) + async def update_bookings_info( + self, + *, + blocked_dates: list[DateInput], + user_id: int | str | None = None, + item_id: int | str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> RealtyActionResult: + """Обновляет информацию о бронированиях недвижимости асинхронно. + + Аргументы: + blocked_dates: передает заблокированные даты бронирования. + user_id: идентифицирует пользователя или аккаунт Авито. + item_id: идентифицирует объявление Авито. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RealtyActionResult` со статусом выполнения операции. + + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPDATE_BOOKINGS_INFO, + path_params={ + "user_id": user_id or self._require_user_id(), + "item_id": item_id or self._require_item_id(), + }, + request=RealtyBookingsUpdateRequest( + blocked_dates=[ + serialize_iso_date(f"blocked_dates[{index}]", blocked_date) + for index, blocked_date in enumerate(blocked_dates) + ] + ), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "GET", + "/realty/v1/accounts/{user_id}/items/{item_id}/bookings", + spec="Краткосрочнаяаренда.json", + operation_id="getRealtyBookings", + method_args={"date_start": "query.date_start", "date_end": "query.date_end"}, + variant="async", + ) + async def list_realty_bookings( + self, + *, + date_start: DateInput, + date_end: DateInput, + with_unpaid: bool | None = None, + user_id: int | str | None = None, + item_id: int | str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> RealtyBookingsResult: + """Возвращает список realty bookings для бронирований недвижимости асинхронно. + + Аргументы: + date_start: задает начальную дату периода бронирований. + date_end: задает конечную дату периода бронирований. + with_unpaid: включает неоплаченные бронирования в результат. + user_id: идентифицирует пользователя или аккаунт Авито. + item_id: идентифицирует объявление Авито. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RealtyBookingsResult` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + LIST_REALTY_BOOKINGS, + path_params={ + "user_id": user_id or self._require_user_id(), + "item_id": item_id or self._require_item_id(), + }, + query=RealtyBookingsQuery( + date_start=serialize_iso_date("date_start", date_start), + date_end=serialize_iso_date("date_end", date_end), + with_unpaid=with_unpaid, + ), + timeout=timeout, + retry=retry, + ) + + def _require_item_id(self) -> str: + """Validate required item id.""" + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return str(self.item_id) + + def _require_user_id(self) -> str: + """Validate required user id.""" + if self.user_id is None: + raise ValidationError("Для операции требуется `user_id`.") + return str(self.user_id) + + +@dataclass(slots=True, frozen=True) +class AsyncRealtyPricing(AsyncDomainObject): + """Async-доменный объект цен краткосрочной аренды.""" + + __swagger_domain__ = "realty" + __sdk_factory__ = "realty_pricing" + __sdk_factory_args__ = {"item_id": "path.item_id", "user_id": "path.user_id"} + + item_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "POST", + "/realty/v1/accounts/{user_id}/items/{item_id}/prices", + spec="Краткосрочнаяаренда.json", + operation_id="postRealtyPrices", + method_args={"periods": "body.prices"}, + variant="async", + ) + async def update_realty_prices( + self, + *, + periods: list[RealtyPricePeriod], + user_id: int | str | None = None, + item_id: int | str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> RealtyActionResult: + """Обновляет realty prices для цен недвижимости асинхронно. + + Аргументы: + periods: передает периоды цен. + user_id: идентифицирует пользователя или аккаунт Авито. + item_id: идентифицирует объявление Авито. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RealtyActionResult` со статусом выполнения операции. + + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + UPDATE_REALTY_PRICES, + path_params={ + "user_id": user_id or self._require_user_id(), + "item_id": item_id or self._require_item_id(), + }, + request=RealtyPricesUpdateRequest(periods=periods), + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + def _require_item_id(self) -> str: + """Validate required item id.""" + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return str(self.item_id) + + def _require_user_id(self) -> str: + """Validate required user id.""" + if self.user_id is None: + raise ValidationError("Для операции требуется `user_id`.") + return str(self.user_id) + + +@dataclass(slots=True, frozen=True) +class AsyncRealtyAnalyticsReport(AsyncDomainObject): + """Async-доменный объект аналитики по недвижимости.""" + + __swagger_domain__ = "realty" + __sdk_factory__ = "realty_analytics_report" + __sdk_factory_args__ = {"item_id": "path.item_id", "user_id": "path.user_id"} + + item_id: int | str | None = None + user_id: int | str | None = None + + @swagger_operation( + "GET", + "/realty/v1/marketPriceCorrespondence/{itemId}/{price}", + spec="Аналитикапонедвижимости.json", + operation_id="market_price_correspondence_v1", + method_args={"price": "path.price"}, + variant="async", + ) + async def get_market_price_correspondence( + self, + *, + item_id: int | str | None = None, + price: int | str, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> RealtyMarketPriceInfo: + """Возвращает соответствие цены объявления рынку недвижимости асинхронно. + + Аргументы: + item_id: идентифицирует объявление Авито. + price: передает цену для аналитического расчета. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RealtyMarketPriceInfo` с типизированными данными ответа API. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_MARKET_PRICE_CORRESPONDENCE, + path_params={ + "itemId": item_id or self._require_item_id(), + "price": price, + }, + timeout=timeout, + retry=retry, + ) + + @swagger_operation( + "POST", + "/realty/v1/report/create/{itemId}", + spec="Аналитикапонедвижимости.json", + operation_id="CreateReportForClassified", + variant="async", + ) + async def get_report_for_classified( + self, + *, + item_id: int | str | None = None, + idempotency_key: str | None = None, + timeout: ApiTimeouts | None = None, + retry: RetryOverride | None = None, + ) -> RealtyAnalyticsInfo: + """Возвращает аналитический отчет по объявлению недвижимости асинхронно. + + Аргументы: + item_id: идентифицирует объявление Авито. + idempotency_key: задает ключ идемпотентности для безопасного повтора write-операции. + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `RealtyAnalyticsInfo` с типизированными данными ответа API. + + Поведение: + Без `idempotency_key` write-вызов не повторяется при сетевых ошибках. + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute( + GET_REPORT_FOR_CLASSIFIED, + path_params={"itemId": item_id or self._require_item_id()}, + idempotency_key=idempotency_key, + timeout=timeout, + retry=retry, + ) + + def _require_item_id(self) -> str: + """Validate required item id.""" + if self.item_id is None: + raise ValidationError("Для операции требуется `item_id`.") + return str(self.item_id) + + +__all__ = ( + "AsyncRealtyAnalyticsReport", + "AsyncRealtyBooking", + "AsyncRealtyListing", + "AsyncRealtyPricing", +) diff --git a/avito/tariffs/__init__.py b/avito/tariffs/__init__.py index 87a5aac..f23b0df 100644 --- a/avito/tariffs/__init__.py +++ b/avito/tariffs/__init__.py @@ -1,6 +1,7 @@ """Пакет tariffs.""" +from avito.tariffs.async_domain import AsyncTariff from avito.tariffs.domain import Tariff from avito.tariffs.models import TariffContractInfo, TariffInfo, TariffLevel -__all__ = ("Tariff", "TariffContractInfo", "TariffInfo", "TariffLevel") +__all__ = ("AsyncTariff", "Tariff", "TariffContractInfo", "TariffInfo", "TariffLevel") diff --git a/avito/tariffs/async_domain.py b/avito/tariffs/async_domain.py new file mode 100644 index 0000000..a54a76d --- /dev/null +++ b/avito/tariffs/async_domain.py @@ -0,0 +1,53 @@ +"""Async-доменные объекты пакета tariffs.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from avito.core import ApiTimeouts, RetryOverride +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation +from avito.tariffs.models import TariffInfo +from avito.tariffs.operations import GET_TARIFF_INFO + + +@dataclass(slots=True, frozen=True) +class AsyncTariff(AsyncDomainObject): + """Async-доменный объект тарифа.""" + + __swagger_domain__ = "tariffs" + __sdk_factory__ = "tariff" + __sdk_factory_args__ = {"tariff_id": "path.tariff_id"} + + tariff_id: int | str | None = None + + @swagger_operation( + "GET", + "/tariff/info/1", + spec="Тарифы.json", + operation_id="getTariffInfo", + variant="async", + ) + async def get_tariff_info( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> TariffInfo: + """Получает информацию о тарифе аккаунта асинхронно. + + Аргументы: + timeout: переопределяет таймауты HTTP-запроса для этого вызова. + retry: переопределяет retry-политику операции: default, enabled или disabled. + + Возвращает: + `TariffInfo` с типизированными данными ответа. + + Поведение: + `timeout` и `retry` действуют только на этот вызов и не меняют настройки клиента. + + Исключения: + AvitoError: ошибка SDK с контекстом operation, status, request_id, attempt, method и endpoint. + """ + + return await self._execute(GET_TARIFF_INFO, timeout=timeout, retry=retry) + + +__all__ = ("AsyncTariff",) diff --git a/avito/testing/__init__.py b/avito/testing/__init__.py index cab024c..a76e870 100644 --- a/avito/testing/__init__.py +++ b/avito/testing/__init__.py @@ -1,5 +1,7 @@ """Публичные тестовые утилиты SDK.""" +from avito.testing.async_fake_transport import AsyncFakeTransport, FanoutPeakRecorder +from avito.testing.async_swagger_fake_transport import AsyncSwaggerFakeTransport from avito.testing.fake_transport import ( FakeResponse, FakeTransport, @@ -17,6 +19,9 @@ __all__ = ( "FakeTransport", + "AsyncFakeTransport", + "AsyncSwaggerFakeTransport", + "FanoutPeakRecorder", "FakeResponse", "JsonValue", "RecordedRequest", diff --git a/avito/testing/async_fake_transport.py b/avito/testing/async_fake_transport.py new file mode 100644 index 0000000..12f9f99 --- /dev/null +++ b/avito/testing/async_fake_transport.py @@ -0,0 +1,252 @@ +"""Async fake transport and helpers for SDK tests.""" + +from __future__ import annotations + +import asyncio +import json +from collections import deque +from collections.abc import Mapping +from typing import cast + +import httpx + +from avito.async_client import AsyncAvitoClient +from avito.auth import AuthSettings +from avito.auth.async_provider import AsyncAuthProvider +from avito.auth.async_token_client import AsyncAlternateTokenClient, AsyncTokenClient +from avito.config import AvitoSettings +from avito.core.async_transport import AsyncTransport +from avito.core.retries import RetryPolicy +from avito.core.types import ApiTimeouts +from avito.testing.fake_transport import JsonValue, RecordedRequest, RouteResponder + + +class AsyncFakeTransport: + """Deterministic async fake transport for SDK contract tests.""" + + def __init__( + self, + *, + base_url: str = "https://api.avito.ru", + fanout_recorder: FanoutPeakRecorder | None = None, + ) -> None: + """Initialize AsyncFakeTransport.""" + self.base_url = base_url.rstrip("/") + self.requests: list[RecordedRequest] = [] + self._routes: dict[tuple[str, str], deque[RouteResponder]] = {} + self._handle_lock = asyncio.Lock() + self._fanout_recorder = fanout_recorder + + def add(self, method: str, path: str, *responses: RouteResponder) -> AsyncFakeTransport: + """Регистрирует один или несколько ответов для HTTP-маршрута.""" + + key = (method.upper(), path) + bucket = self._routes.setdefault(key, deque()) + bucket.extend(responses) + return self + + def add_json( + self, + method: str, + path: str, + payload: JsonValue, + *, + status_code: int = 200, + headers: Mapping[str, str] | None = None, + ) -> AsyncFakeTransport: + """Регистрирует JSON-ответ для HTTP-маршрута.""" + + return self.add(method, path, httpx.Response(status_code, json=payload, headers=headers)) + + def build( + self, + *, + retry_policy: RetryPolicy | None = None, + user_id: int | None = None, + authenticated: bool = False, + auth_settings: AuthSettings | None = None, + ) -> AsyncTransport: + """Создаёт низкоуровневый AsyncTransport поверх fake transport.""" + + settings, auth_provider, http_client = self._build_parts( + retry_policy=retry_policy, + user_id=user_id, + authenticated=authenticated, + auth_settings=auth_settings, + ) + return AsyncTransport( + settings, + auth_provider=auth_provider, + client=http_client, + sleep=lambda _: asyncio.sleep(0), + ) + + def as_client( + self, + *, + user_id: int | None = None, + retry_policy: RetryPolicy | None = None, + authenticated: bool = False, + auth_settings: AuthSettings | None = None, + ) -> AsyncAvitoClient: + """Создает публичный `AsyncAvitoClient` поверх fake transport.""" + + settings, auth_provider, http_client = self._build_parts( + retry_policy=retry_policy, + user_id=user_id, + authenticated=authenticated, + auth_settings=auth_settings, + ) + transport = AsyncTransport( + settings, + auth_provider=auth_provider, + client=http_client, + sleep=lambda _: asyncio.sleep(0), + ) + return AsyncAvitoClient._from_transport( + settings, + transport=transport, + auth_provider=auth_provider or AsyncAuthProvider(settings.auth), + ) + + def count(self, *, method: str | None = None, path: str | None = None) -> int: + """Возвращает число перехваченных запросов с опциональной фильтрацией.""" + + return len( + [ + request + for request in self.requests + if (method is None or request.method == method.upper()) + and (path is None or request.path == path) + ] + ) + + def last(self, *, method: str | None = None, path: str | None = None) -> RecordedRequest: + """Возвращает последний перехваченный запрос с опциональной фильтрацией.""" + + matches = [ + request + for request in self.requests + if (method is None or request.method == method.upper()) + and (path is None or request.path == path) + ] + if not matches: + raise AssertionError(f"No requests matched method={method!r} path={path!r}") + return matches[-1] + + def _build_parts( + self, + *, + retry_policy: RetryPolicy | None, + user_id: int | None, + authenticated: bool, + auth_settings: AuthSettings | None, + ) -> tuple[AvitoSettings, AsyncAuthProvider | None, httpx.AsyncClient]: + """Build parts.""" + resolved_auth = auth_settings or AuthSettings( + client_id="fake-client-id", + client_secret="fake-client-secret", + ) + settings = AvitoSettings( + base_url=self.base_url, + user_id=user_id, + auth=resolved_auth, + retry_policy=retry_policy or RetryPolicy(), + timeouts=ApiTimeouts(), + ) + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(self._handle), + base_url=self.base_url, + ) + auth_provider = None + if authenticated: + auth_provider = AsyncAuthProvider( + resolved_auth, + token_client=AsyncTokenClient( + resolved_auth, + client=http_client, + sdk_settings=settings, + ), + alternate_token_client=AsyncAlternateTokenClient( + resolved_auth, + client=http_client, + sdk_settings=settings, + ), + autoteka_token_client=AsyncTokenClient( + resolved_auth, + token_url=resolved_auth.autoteka_token_url, + client=http_client, + sdk_settings=settings, + ), + ) + return settings, auth_provider, http_client + + async def _handle(self, request: httpx.Request) -> httpx.Response: + """Handle handle.""" + if self._fanout_recorder is not None: + await self._fanout_recorder.enter() + try: + return await self._handle_recorded(request) + finally: + if self._fanout_recorder is not None: + await self._fanout_recorder.exit() + + async def _handle_recorded(self, request: httpx.Request) -> httpx.Response: + """Handle recorded.""" + async with self._handle_lock: + recorded = RecordedRequest( + method=request.method.upper(), + path=request.url.path, + params=dict(request.url.params), + headers=dict(request.headers), + json_body=self._decode_json(request), + content=request.content, + ) + self.requests.append(recorded) + key = (recorded.method, recorded.path) + if key not in self._routes: + available = ", ".join(f"{method} {path}" for method, path in sorted(self._routes)) + raise AssertionError( + "Маршрут не прописан в AsyncFakeTransport: " + f"{recorded.method} {recorded.path}. " + f"Добавьте route_sequence или add_json для этого пути. Доступные: {available}" + ) + responders = self._routes[key] + responder = responders[0] if len(responders) == 1 else responders.popleft() + response = responder(recorded) if callable(responder) else responder + response.request = request + return response + + @staticmethod + def _decode_json(request: httpx.Request) -> JsonValue: + """Decode json.""" + if not request.content: + return None + try: + return cast(JsonValue, json.loads(request.content.decode())) + except json.JSONDecodeError: + return None + + +class FanoutPeakRecorder: + """Считает пик одновременно выполняющихся async fake-запросов.""" + + def __init__(self) -> None: + """Initialize FanoutPeakRecorder.""" + self._lock = asyncio.Lock() + self._active = 0 + self.peak = 0 + + async def enter(self) -> None: + """Record fan-out enter event.""" + async with self._lock: + self._active += 1 + self.peak = max(self.peak, self._active) + + async def exit(self) -> None: + """Record fan-out exit event.""" + async with self._lock: + self._active -= 1 + + +__all__ = ("AsyncFakeTransport", "FanoutPeakRecorder") diff --git a/avito/testing/async_swagger_fake_transport.py b/avito/testing/async_swagger_fake_transport.py new file mode 100644 index 0000000..51e94c4 --- /dev/null +++ b/avito/testing/async_swagger_fake_transport.py @@ -0,0 +1,169 @@ +"""Async Swagger-aware fake transport placeholder for async contract tests.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping + +import httpx + +from avito.async_client import AsyncAvitoClient +from avito.auth import AuthSettings +from avito.auth.async_token_client import AsyncAlternateTokenClient, AsyncTokenClient +from avito.core.swagger_discovery import DiscoveredSwaggerBinding +from avito.core.swagger_registry import SwaggerOperation, SwaggerRegistry +from avito.testing.async_fake_transport import AsyncFakeTransport +from avito.testing.fake_transport import JsonValue, RecordedRequest +from avito.testing.swagger_fake_transport import SwaggerFakeTransport, SwaggerRoute, success_payload + + +class AsyncSwaggerFakeTransport(AsyncFakeTransport): + """Async fake transport that registers routes by Swagger operation key.""" + + def __init__( + self, + *, + registry: SwaggerRegistry, + base_url: str = "https://api.avito.ru", + ) -> None: + """Initialize AsyncSwaggerFakeTransport.""" + super().__init__(base_url=base_url) + self.registry = registry + self._sync_helper = SwaggerFakeTransport(registry=registry, base_url=base_url) + self._swagger_routes: dict[str, SwaggerRoute] = self._sync_helper._swagger_routes + + def add_operation( + self, + operation_key: str, + payload: JsonValue, + *, + status_code: int = 200, + headers: Mapping[str, str] | None = None, + ) -> AsyncSwaggerFakeTransport: + """Register response for one Swagger operation key.""" + + self._sync_helper.add_operation( + operation_key, + payload, + status_code=status_code, + headers=headers, + ) + return self + + def add_success_operation( + self, + operation_key: str, + *, + payload: JsonValue | None = None, + headers: Mapping[str, str] | None = None, + ) -> AsyncSwaggerFakeTransport: + """Register a deterministic success response for one Swagger operation.""" + + operation = self.operation(operation_key) + status_code = int(operation.responses[0].status_code) + return self.add_operation( + operation_key, + success_payload(operation) if payload is None else payload, + status_code=status_code, + headers=headers, + ) + + def operation(self, operation_key: str) -> SwaggerOperation: + """Return operation by key or raise an assertion error.""" + + return self._sync_helper.operation(operation_key) + + async def invoke_binding( + self, + binding: DiscoveredSwaggerBinding, + *, + client: AsyncAvitoClient | None = None, + ) -> object: + """Build and invoke async SDK call from discovered Swagger binding metadata.""" + + if binding.operation_key is None: + raise AssertionError(f"Привязка Swagger неоднозначна: {binding.sdk_method}") + if binding.domain == "auth": + target = self._build_auth_target(binding) + method = getattr(target, binding.method_name) + return await method(**self._build_arguments(binding.method_args, method)) + sdk_client = client or self.as_client(user_id=7) + target = self._build_target(sdk_client, binding) + method = getattr(target, binding.method_name) + return await method(**self._build_arguments(binding.method_args, method)) + + async def _handle_recorded(self, request: httpx.Request) -> httpx.Response: + """Handle recorded.""" + async with self._handle_lock: + recorded = RecordedRequest( + method=request.method.upper(), + path=request.url.path, + params=dict(request.url.params), + headers=dict(request.headers), + json_body=self._decode_json(request), + content=request.content, + ) + self.requests.append(recorded) + + route = self._match_route(recorded) + self._validate_request(route.operation, recorded) + response = httpx.Response( + route.status_code, + json=route.payload, + headers=dict(route.headers), + ) + response.request = request + return response + + def _build_target( + self, + client: AsyncAvitoClient, + binding: DiscoveredSwaggerBinding, + ) -> object: + """Build target.""" + if binding.factory is None: + raise AssertionError(f"Binding не содержит AsyncAvitoClient factory: {binding.sdk_method}") + factory = getattr(client, binding.factory) + return factory(**self._build_arguments(binding.factory_args, factory)) + + def _build_auth_target(self, binding: DiscoveredSwaggerBinding) -> object: + """Build auth target.""" + settings = AuthSettings( + client_id="fake-client-id", + client_secret="fake-client-secret", + refresh_token="fake-refresh-token", + scope="fake-scope", + token_url=binding.path, + alternate_token_url=binding.path, + autoteka_token_url="/token", + autoteka_client_id="fake-autoteka-client-id", + autoteka_client_secret="fake-autoteka-client-secret", + autoteka_scope="autoteka:read", + ) + client = httpx.AsyncClient( + transport=httpx.MockTransport(self._handle), + base_url=self.base_url, + ) + if binding.class_name == "AsyncAlternateTokenClient": + return AsyncAlternateTokenClient(settings=settings, client=client) + if binding.class_name == "AsyncTokenClient": + return AsyncTokenClient(settings=settings, client=client) + raise AssertionError(f"Неподдерживаемый async auth binding: {binding.sdk_method}") + + def _build_arguments( + self, + mapping: Mapping[str, str], + callable_object: Callable[..., object], + ) -> dict[str, object]: + """Build arguments.""" + return self._sync_helper._build_arguments(mapping, callable_object) + + def _match_route(self, request: RecordedRequest) -> SwaggerRoute: + """Match route.""" + return self._sync_helper._match_route(request) + + def _validate_request(self, operation: SwaggerOperation, request: RecordedRequest) -> None: + """Validate request.""" + self._sync_helper._validate_request(operation, request) + + +__all__ = ("AsyncSwaggerFakeTransport",) diff --git a/docs/dev/preflight-async-m1.md b/docs/dev/preflight-async-m1.md new file mode 100644 index 0000000..39ce74e --- /dev/null +++ b/docs/dev/preflight-async-m1.md @@ -0,0 +1,279 @@ +# Pre-flight Async M1 + +Generated: 2026-05-08 + +Repository HEAD: `b633764633b1fa79f537dbe2955dc91614e1caf8` + +This artifact records the local pre-flight required before PR M1 of the dual-mode SDK +plan. The goal is provenance, not implementation: it captures the current sync-only +state, known probes, baseline hashes, and the resolver smoke decision. + +## Environment + +```yaml +python_requires: ">=3.12,<4.0" +python_runtime: "3.14.0 (main, Oct 28 2025, 12:11:51) [Clang 20.1.4 ]" +pytest: "8.4.2" +httpx: "0.28.1" +poetry_lock_sha256: "08a6425ee9317b1b9074184ee1e03f0f57ff793c5b37ade73cdc33316246e7b3" +``` + +`pyproject.toml` currently has no `pytest-asyncio`, `asyncio_mode`, +`asyncio_default_fixture_loop_scope`, or `filterwarnings` entries. M1 must add the +asyncio pytest settings described in `todo.md`. + +## Private Auth Cache Probes + +Command: + +```bash +rg -n "\._access_token|\._refresh_token|\._autoteka_access_token" tests +``` + +Result: + +```text +tests/core/test_authentication.py:123: provider._access_token = replace( +tests/core/test_authentication.py:124: provider._access_token, # type: ignore[arg-type, attr-defined] +``` + +Decision: the M1 `AuthProvider` property shim for `_access_token` is sufficient for +the current test suite. There are no direct `_refresh_token` or +`_autoteka_access_token` probes in `tests/` today, but the planned shims can still cover +all three fields for compatibility. + +## Paginator Usage + +Command: + +```bash +rg -n "\bPaginator\b" avito +``` + +Domain call sites: + +```text +avito/accounts/domain.py:170: return Paginator(fetch_page).as_list(first_page=fetch_page(1, None)) +avito/accounts/domain.py:383: return Paginator(fetch_page).as_list(first_page=fetch_page(1, None)) +avito/ads/domain.py:266: return Paginator( +avito/ads/domain.py:1183: return Paginator(fetch_page).as_list(first_page=fetch_page(1, None)) +``` + +Infrastructure/import references also found: + +```text +avito/accounts/domain.py:36: Paginator, +avito/ads/domain.py:76: Paginator, +avito/core/pagination.py:183:class Paginator[ItemT]: +avito/core/pagination.py:230:__all__ = ("PaginatedList", "Paginator", "PageFetcher") +avito/core/__init__.py:21:from avito.core.pagination import PaginatedList, Paginator +avito/core/__init__.py:55: "Paginator", +``` + +Decision: current public domain usage ends in `.as_list(...)`; there is no direct +public domain return of `Paginator`. + +## PaginatedList List-API Consumers + +Broad command used to find list-like consumers around current pagination tests and +domains: + +```bash +rg -n "\bPaginatedList\b|\bPaginator\b|\.materialize\(\)|\[[0-9]+\]" \ + avito/accounts/domain.py avito/ads/domain.py \ + tests/domains/accounts/test_accounts.py tests/domains/ads/test_ads.py \ + tests/core/test_transport.py tests/contracts/test_swagger_contracts.py +``` + +Relevant runtime/list-API observations: + +```text +tests/domains/accounts/test_accounts.py:56: assert len(history.materialize()) == 1 +tests/domains/accounts/test_accounts.py:57: assert history[0].operation_type == "payment" +tests/domains/accounts/test_accounts.py:126: assert items[0].title == "Объявление" +tests/domains/ads/test_ads.py:39: assert items[3].item_id == 104 +tests/domains/ads/test_ads.py:41: assert [item.title for item in items.materialize()] == [ +tests/domains/ads/test_ads.py:70: assert [item.item_id for item in items.materialize()] == [101, 102, 103] +tests/domains/ads/test_ads.py:90: assert [item.item_id for item in items.materialize()] == list(range(101, 126)) +tests/core/test_transport.py:752: assert items[0] == 1 +tests/core/test_transport.py:753: assert items[3] == 4 +tests/core/test_transport.py:756: assert items.materialize() == [1, 2, 3, 4, 5] +tests/core/test_transport.py:765: assert empty.materialize() == [] +tests/core/test_transport.py:774: _ = items[2] +tests/contracts/test_swagger_contracts.py:335: assert isinstance(result, PaginatedList) +tests/contracts/test_swagger_contracts.py:336: assert isinstance(result[0], EmployeeItem) +``` + +Decision: async doubles must replace direct indexing/length assumptions with +`await materialize()` or `loaded_count` where the behavior is ported. Existing sync-only +tests stay unchanged. + +## Existing Async Tests + +Command: + +```bash +rg -n "^async def test_" tests +``` + +Result: no matches. + +Decision: enabling `asyncio_mode = "strict"` in M1 will not newly skip any existing +async tests, because none exist today. + +## Deprecated Public Methods + +Command: + +```bash +rg -n "@deprecated_method|deprecated_method\(" avito/cpa avito/ads +``` + +Result: + +```text +avito/cpa/domain.py:491: @deprecated_method( +avito/cpa/domain.py:541: @deprecated_method( +avito/cpa/domain.py:585: @deprecated_method( +avito/ads/domain.py:1416: @deprecated_method( +avito/ads/domain.py:1457: @deprecated_method( +avito/ads/domain.py:1523: @deprecated_method( +avito/ads/domain.py:1558: @deprecated_method( +``` + +Decision: the plan's expected count is current: 3 in `cpa`, 4 in `ads`, 7 total. +M1 must make `deprecated_method` async-aware before M6/M11. + +## OperationSpec Resolver Smoke + +Smoke: + +```python +from avito.core.operations import OperationSpec + +SOME_SPEC = OperationSpec(name="smoke", method="GET", path="/smoke") + +class AsyncSmokeDomain: + async def m(self): + return await self._execute(SOME_SPEC) +``` + +Runner result: + +```text +pass +1 +smoke +``` + +Decision: `_operation_specs_for_sdk_method` currently resolves an async method's +module-level `SOME_SPEC` through `inspect.unwrap(method).__globals__`. M1 does not need +the AST fallback or class-level `__operation_specs__` fallback unless later edits change +this behavior. + +## Reference Builder Join Points + +Current state of `docs/site/assets/_gen_reference.py`: + +```yaml +public_domain_packages: "PACKAGE_ROOT.glob(\"*/domain.py\")" +excluded_packages: ["auth", "core", "testing"] +public_domain_classes: + imports: "avito." + source: "__all__" + class_filter: "issubclass(value, DomainObject)" + module_filter: "value.__module__.startswith(f\"avito.{package}.\")" +public_domain_methods: + predicate: "inspect.isfunction" + public_filter: "not name.startswith(\"_\")" + qualname_filter: "value.__qualname__.startswith(f\"{domain_class.__name__}.\")" +write_domain_pages: "writes one mkdocstrings directive: ::: avito." +``` + +Decision: M1 must extend this to import `domain.py` and `async_domain.py` directly, +filter `AsyncDomainObject` descendants, and write explicit class directives in sync +class -> async class order. + +## Architecture And Docstring Linter Join Points + +Current state of `scripts/lint_architecture.py`: + +```yaml +public_domain_method_paths: "avito//domain.py only" +public_method_ast_node: "ast.FunctionDef only" +collect_domain_class_methods: "ast.FunctionDef only" +``` + +Current state of `scripts/lint_docstrings.py`: + +```yaml +paths: "sorted((root / \"avito\").glob(\"*/domain.py\"))" +public_method_ast_node: "ast.FunctionDef only" +``` + +Decision: M1 must include `async_domain.py` and treat `ast.AsyncFunctionDef` as +equivalent for public async methods and model serializer method collection where relevant. + +## Deprecation Wrapper Join Point + +Current `avito/core/deprecation.py::deprecated_method` always returns a sync +`wrapped(*args, **kwargs)` and directly returns `method(*args, **kwargs)`. + +Decision: M1 must branch on coroutine functions and return an `async def` wrapper that +awaits the original method while preserving `__sdk_deprecation__`. + +## Swagger Factory Join Point + +Current `avito/core/swagger_linter.py::_validate_factory` behavior: + +```yaml +auth_binding_without_factory: "skipped only when binding.domain == \"auth\" and factory is None" +non_auth_without_factory: "SWAGGER_BINDING_FACTORY_MISSING" +factory_lookup: "getattr(AvitoClient, binding.factory, None)" +factory_not_callable: "SWAGGER_BINDING_FACTORY_NOT_FOUND" +signature_check: "_validate_signature_mapping(..., mapping=binding.factory_args)" +variant_awareness: "none" +``` + +Decision: M1 must make this variant-aware and class-gated for async bindings, while +preserving current sync behavior. + +## Baseline + +The exact command from `todo.md` failed because this repository currently has no +`tests/auth` directory: + +```text +ERROR: file or directory not found: tests/auth +``` + +Adjusted collection command: + +```bash +poetry run pytest --collect-only -q tests/core tests/domains tests/contracts | rg '::' > /tmp/baseline_nodeids.txt +``` + +Adjusted baseline execution passed by passing nodeids as exact subprocess argv entries, +because parametrized nodeids include spaces and `$(cat /tmp/baseline_nodeids.txt)` splits +them incorrectly: + +```text +2070 passed in 10.85s +``` + +Baseline files: + +```yaml +baseline_nodeids: + path: "/tmp/baseline_nodeids.txt" + line_count: 2070 + sha256: "373a692216014e9a3cae5c57ccb4e1ca14f94fcf06c484ae8602b141df53a6d9" +baseline_main: + path: "/tmp/baseline_main.txt" + sha256: "1820f9dccbad66227dcf5281ab22333c5c7b6ef2ea2df5c0f1fa0cd858c09023" + result: "2070 passed in 10.85s" +``` + +Decision: use these adjusted baseline hashes for M1 sync-regression comparison unless +`tests/auth` is created before the M1 branch starts. If that happens, rerun pre-flight and +update this artifact. diff --git a/docs/site/assets/_gen_reference.py b/docs/site/assets/_gen_reference.py index 81693f6..c3f6ca7 100644 --- a/docs/site/assets/_gen_reference.py +++ b/docs/site/assets/_gen_reference.py @@ -8,7 +8,7 @@ import mkdocs_gen_files -from avito.core.domain import DomainObject +from avito.core.domain import AsyncDomainObject, DomainObject from avito.core.swagger_discovery import discover_swagger_bindings from avito.core.swagger_linter import lint_swagger_bindings from avito.core.swagger_registry import load_swagger_registry @@ -21,12 +21,27 @@ def public_domain_packages() -> list[str]: return sorted( - path.parent.name - for path in PACKAGE_ROOT.glob("*/domain.py") - if path.parent.name not in EXCLUDED_PACKAGES + { + path.parent.name + for path in ( + *PACKAGE_ROOT.glob("*/domain.py"), + *PACKAGE_ROOT.glob("*/async_domain.py"), + ) + if path.parent.name not in EXCLUDED_PACKAGES + } ) +def _is_public_domain_class(value: object) -> bool: + return ( + inspect.isclass(value) + and value not in {DomainObject, AsyncDomainObject} + and ( + issubclass(value, DomainObject) + or (value.__name__.startswith("Async") and issubclass(value, AsyncDomainObject)) + ) + ) + def package_title(package: str) -> str: return package @@ -43,18 +58,19 @@ def public_enums(package: str) -> list[type[Enum]]: def public_domain_classes(package: str) -> list[type[DomainObject]]: - module = importlib.import_module(f"avito.{package}") - names = getattr(module, "__all__", ()) + modules = [] + for suffix in ("domain", "async_domain"): + try: + modules.append(importlib.import_module(f"avito.{package}.{suffix}")) + except ModuleNotFoundError: + continue classes: list[type[DomainObject]] = [] - for name in names: - value = getattr(module, name, None) - if ( - inspect.isclass(value) - and issubclass(value, DomainObject) - and value is not DomainObject - and value.__module__.startswith(f"avito.{package}.") - ): - classes.append(value) + for module in modules: + for _, value in inspect.getmembers(module, inspect.isclass): + if value.__module__ != module.__name__: + continue + if _is_public_domain_class(value): + classes.append(value) return classes @@ -82,7 +98,8 @@ def write_domain_pages(packages: list[str]) -> list[str]: for enum_class in enums: file.write(f"- [`{enum_class.__name__}`](../enums.md#{enum_class.__name__})\n") file.write("\n") - file.write(f"::: avito.{package}\n") + for domain_class in public_domain_classes(package): + file.write(f"::: {domain_class.__module__}.{domain_class.__name__}\n\n") mkdocs_gen_files.set_edit_path(page, Path(f"avito/{package}/__init__.py")) return pages diff --git a/docs/site/explanations/async-domain-template.md b/docs/site/explanations/async-domain-template.md new file mode 100644 index 0000000..03d0b35 --- /dev/null +++ b/docs/site/explanations/async-domain-template.md @@ -0,0 +1,63 @@ +# Async Domain Template + +Async-домен добавляется как `avito//async_domain.py` рядом с sync `domain.py`. +Файл содержит `Async(AsyncDomainObject)` для каждого портированного sync-класса. + +Минимальный шаблон: + +```python +from dataclasses import dataclass + +from avito.core import ApiTimeouts, RetryOverride +from avito.core.domain import AsyncDomainObject +from avito.core.swagger import swagger_operation +from avito..models import ResultModel +from avito..operations import GET_RESULT + + +@dataclass(slots=True, frozen=True) +class AsyncExample(AsyncDomainObject): + __swagger_domain__ = "example" + __sdk_factory__ = "example" + __sdk_factory_args__ = {"item_id": "path.item_id"} + + item_id: int | str | None = None + + @swagger_operation( + "GET", + "/example/{item_id}", + spec="Example.json", + operation_id="getExample", + variant="async", + ) + async def get( + self, *, timeout: ApiTimeouts | None = None, retry: RetryOverride | None = None + ) -> ResultModel: + return await self._execute( + GET_RESULT, + path_params={"item_id": self.item_id}, + timeout=timeout, + retry=retry, + ) +``` + +Checklist for each domain port: + +- Mirror every public sync method with `async def`. +- Keep class metadata equal to the sync class: `__swagger_domain__`, `__sdk_factory__`, + and `__sdk_factory_args__`. +- Use the same `OperationSpec`, request/query DTOs, and response models as the sync domain. +- Add `@swagger_operation(..., variant="async")`; do not duplicate schema details in the decorator. +- Return `AsyncPaginatedList[T]` only where the sync method returns `PaginatedList[T]`. +- Export `Async` from `avito//__init__.py`. +- Add the matching `AsyncAvitoClient.()` method when the sync factory exists. +- Add async tests for the golden path and async risks: mapped HTTP errors, retry/rate limit + behavior where relevant, and transport errors. +- Run `make async-parity-lint`, `make swagger-lint`, the domain tests, and async contracts. + +PoC notes from `tariffs`: + +- The first real `async_domain.py` exposed that `public_domain_packages()` must deduplicate + packages when both `domain.py` and `async_domain.py` exist. +- Reference generation must import `domain.py` and `async_domain.py` directly and write separate + mkdocstrings directives in sync then async order. diff --git a/docs/site/explanations/domain-architecture-v2.md b/docs/site/explanations/domain-architecture-v2.md index 253d742..365b578 100644 --- a/docs/site/explanations/domain-architecture-v2.md +++ b/docs/site/explanations/domain-architecture-v2.md @@ -1,8 +1,9 @@ # Целевая структура доменов Эта страница фиксирует архитектуру доменных пакетов SDK. Все API-домены SDK -используют v2 layout: публичные методы находятся в `domain.py`, HTTP-контракты -в `operations.py` или `operations/`, а модели, enum-ы и payload mapping +используют v2 layout: публичные sync-методы находятся в `domain.py`, async-зеркало +портированных классов находится в `async_domain.py`, HTTP-контракты в +`operations.py` или `operations/`, а модели, enum-ы и payload mapping принадлежат `models.py` или `models/`. ## Основной принцип @@ -27,6 +28,7 @@ API-доменов. Domain-level `client.py`, `mappers.py` и standalone `enums. avito/ratings/ __init__.py domain.py + async_domain.py operations.py models.py ``` @@ -35,7 +37,8 @@ avito/ratings/ | Файл | Ответственность | |---|---| -| `domain.py` | Публичные `DomainObject`-классы, reference-ready docstring-и, `@swagger_operation(...)`, бизнес-валидация и сбор публичных request-моделей | +| `domain.py` | Публичные sync `DomainObject`-классы, reference-ready docstring-и, `@swagger_operation(..., variant="sync")`, бизнес-валидация и сбор публичных request-моделей | +| `async_domain.py` | Публичные async `AsyncDomainObject`-классы для портированных доменов; методы зеркалируют sync-сигнатуры, используют `async def` и `@swagger_operation(..., variant="async")` | | `operations.py` | Внутренние `OperationSpec`: HTTP method, path, operation context, retry policy и response/request model classes | | `models.py` | Dataclass-модели, enum-ы, `from_payload()`, `to_payload()`, `to_params()` и нормализация | diff --git a/docs/site/explanations/swagger-binding-subsystem.md b/docs/site/explanations/swagger-binding-subsystem.md index 8bf9c46..6d60c04 100644 --- a/docs/site/explanations/swagger-binding-subsystem.md +++ b/docs/site/explanations/swagger-binding-subsystem.md @@ -38,6 +38,7 @@ Swagger/OpenAPI-файлы в `docs/avito/api/*.json` остаются един method_args: Mapping[str, str] | None = None, deprecated: bool = False, legacy: bool = False, + variant: Literal["sync", "async"] = "sync", ) ``` @@ -103,7 +104,20 @@ Registry дополнительно строит normalized JSON schema tree д ## Discovery -Discovery импортирует пакет `avito`, но не создаёт `AvitoClient`, не читает обязательные env vars и не делает сетевых вызовов. Сканируются публичные domain classes из `avito//domain.py` и заранее описанные non-domain exceptions, например low-level auth token bindings. +Discovery импортирует пакет `avito`, но не создаёт `AvitoClient`, не читает обязательные env vars и не делает сетевых вызовов. Сканируются публичные domain classes из `avito//domain.py`, async companions из `avito//async_domain.py`, если они существуют, и заранее описанные non-domain exceptions, например low-level auth token bindings. + +## Sync/async variants + +Binding identity is variant-aware. The sync surface uses +`@swagger_operation(..., variant="sync")` by default; async mirrors use +`variant="async"`. The duplicate-binding key is `(operation_key, variant)`, so one +Swagger operation may have one sync binding and one async binding. + +During migration async coverage is class-gated: if an `Async` class exists, all +Swagger-bound methods of sync class `` must have async bindings. If the class has +not been ported yet, its operations do not enter async expected coverage. Auth token +bindings are discovered from `avito.auth.async_token_client` independently from domain +factories. Игнорируются: @@ -127,7 +141,7 @@ make swagger-coverage ``` Non-strict mode валидирует specs и уже найденные bindings. Strict mode -дополнительно требует, чтобы каждая Swagger operation имела ровно один binding, +дополнительно требует, чтобы каждая Swagger operation имела ровно один sync binding, каждый API-domain binding исполнялся через ровно один `OperationSpec`, method/path этого `OperationSpec` совпадали со Swagger operation, а API-domain `OperationSpec` без публичного binding отсутствовали. `make swagger-lint` @@ -136,6 +150,11 @@ Non-strict mode валидирует specs и уже найденные bindings через `make swagger-update`. `make swagger-coverage` дополнительно запускает полный Swagger contract suite и входит в `make check`. +JSON report keeps the historical sync `binding` field and adds +`bindings_by_variant` plus `summary.variants.sync` / `summary.variants.async`, so +generated reference pages can show both SDK surfaces without breaking current sync +consumers. + JSON report используется как стабильный machine-readable API для generated reference и coverage: ```json diff --git a/docs/site/how-to/.pages b/docs/site/how-to/.pages index 74c0354..537691a 100644 --- a/docs/site/how-to/.pages +++ b/docs/site/how-to/.pages @@ -1,6 +1,7 @@ nav: - index.md - auth-and-config.md + - async.md - account-profile.md - ad-listing-and-stats.md - promotion-dry-run.md diff --git a/docs/site/how-to/async.md b/docs/site/how-to/async.md new file mode 100644 index 0000000..c2c1a01 --- /dev/null +++ b/docs/site/how-to/async.md @@ -0,0 +1,168 @@ +# Async API + +`AsyncAvitoClient` повторяет доменную поверхность `AvitoClient`, но все сетевые +методы вызываются через `await`. Клиент обязательно открывается через `async with`: +в этот момент создаются `httpx.AsyncClient`, async locks и transport. + +```python +from avito import AsyncAvitoClient + + +async def load_profile() -> str | None: + async with AsyncAvitoClient.from_env() as avito: + profile = await avito.account().get_self() + return profile.name +``` + +## Переписать sync-вызов на async + +Sync: + +```python +from avito import AvitoClient + +with AvitoClient.from_env() as avito: + orders = avito.order().list() + label = avito.order_label(task_id=42).download() +``` + +Async: + +```python +from avito import AsyncAvitoClient + +async with AsyncAvitoClient.from_env() as avito: + orders = await avito.order().list() + label = await avito.order_label(task_id=42).download() +``` + +Для пагинации sync `PaginatedList` и async `AsyncPaginatedList` отличаются: +async-контейнер не является `list`, поэтому используйте `async for` или +`await materialize()`. + +```python +async with AsyncAvitoClient.from_env() as avito: + page = await avito.ad(user_id=123).list(limit=100) + items = await page.materialize() +``` + +## Тестирование без HTTP + +```python +from avito.testing import AsyncFakeTransport + + +async def test_orders_summary() -> None: + fake = ( + AsyncFakeTransport() + .add_json("GET", "/order-management/1/orders", {"orders": []}) + ) + client = fake.as_client(user_id=123) + + summary = await client.order_summary() + + assert summary.total_orders == 0 + await client.aclose() +``` + +## Ограничения + +- `AsyncPaginatedList` не поддерживает list API и конкурентную итерацию одного + экземпляра. +- Бинарные ответы, включая PDF-этикетки заказов, загружаются целиком в память. + Streaming API в версии 2.1.0 нет. +- Один `AsyncAvitoClient` нельзя переносить между event loop. Создавайте клиент в + том loop, где он будет использоваться. + +## Использование под ASGI (FastAPI / aiohttp / Starlette) + +### FastAPI lifespan + +Создавайте клиент в lifespan, храните его в `app.state` и закрывайте на shutdown. + +```python +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import Depends, FastAPI, Request + +from avito import AsyncAvitoClient + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + async with AsyncAvitoClient.from_env() as avito: + app.state.avito = avito + yield + + +app = FastAPI(lifespan=lifespan) + + +def get_avito(request: Request) -> AsyncAvitoClient: + return request.app.state.avito + + +@app.get("/orders/summary") +async def orders_summary(avito: AsyncAvitoClient = Depends(get_avito)) -> dict[str, object]: + summary = await avito.order_summary() + return summary.to_dict() +``` + +### aiohttp cleanup_ctx + +```python +from collections.abc import AsyncIterator + +from aiohttp import web + +from avito import AsyncAvitoClient + +avito_key = web.AppKey("avito", AsyncAvitoClient) + + +async def avito_client_ctx(app: web.Application) -> AsyncIterator[None]: + async with AsyncAvitoClient.from_env() as avito: + app[avito_key] = avito + yield + + +async def orders_summary(request: web.Request) -> web.Response: + summary = await request.app[avito_key].order_summary() + return web.json_response(summary.to_dict()) + + +app = web.Application() +app.cleanup_ctx.append(avito_client_ctx) +app.router.add_get("/orders/summary", orders_summary) +``` + +### Per-worker isolation + +Под Gunicorn/Uvicorn создавайте один `AsyncAvitoClient` на worker process. Не +создавайте клиент в master process до fork и не передавайте его между процессами: +у каждого worker свой event loop, connection pool и набор async locks. + +### Запрещённый паттерн + +```python +from avito import AsyncAvitoClient + +avito = AsyncAvitoClient.from_env() + + +async def handler() -> dict[str, object]: + await avito.__aenter__() # ❌ loop-bound ресурсы создаются в request handler + return (await avito.order_summary()).to_dict() +``` + +Такой код привязывает внутренний `httpx.AsyncClient` к первому loop, который +коснулся handler. В тестах, background scheduler или другом worker loop это +приведёт к cross-loop ошибкам и утечкам соединений. + +### Background tasks + +`asyncio.create_task()` и FastAPI `BackgroundTasks`, которые исполняются в том же +event loop, могут использовать app-level клиент из lifespan. Для process pool, +отдельного worker или внешнего scheduler создавайте отдельный `AsyncAvitoClient` +внутри этого процесса и его loop. diff --git a/docs/site/how-to/index.md b/docs/site/how-to/index.md index 2c835b4..7c1a595 100644 --- a/docs/site/how-to/index.md +++ b/docs/site/how-to/index.md @@ -5,6 +5,7 @@ How-to раздел собирает рецепты для конкретных | Рецепт | Задача | |---|---| | [Авторизация и конфигурация](auth-and-config.md) | Создать клиент через env, явные ключи или `AvitoSettings` | +| [Async API](async.md) | Использовать `AsyncAvitoClient`, ASGI lifespan и async fake transport | | [Профиль, баланс и иерархия аккаунта](account-profile.md) | Получить профиль, баланс, историю операций и данные сотрудников | | [Объявления, статистика и продвижение](ad-listing-and-stats.md) | Найти объявления, открыть карточку, прочитать статистику и подготовить VAS | | [Продвижение с dry-run](promotion-dry-run.md) | Проверить payload write-операции без сетевого вызова | diff --git a/docs/site/index.md b/docs/site/index.md index 6a0a174..c9bfcb9 100644 --- a/docs/site/index.md +++ b/docs/site/index.md @@ -7,7 +7,7 @@ hide:
-**`avito-py`** — синхронный Python SDK для работы с Avito API через единый объектный фасад `AvitoClient`. +**`avito-py`** — Python SDK для работы с Avito API через sync/async фасады `AvitoClient` и `AsyncAvitoClient`. Скрывает transport, OAuth и retry-логику. Возвращает типизированные `dataclass`-модели. Покрывает 204 операции Avito API. ```bash @@ -39,7 +39,7 @@ pip install avito-py --- - Пошаговые рецепты: авторизация, мессенджер, заказы, пагинация, тестирование и другие. + Пошаговые рецепты: авторизация, async lifecycle, мессенджер, заказы, пагинация, тестирование и другие. [:octicons-arrow-right-24: How-to рецепты](how-to/index.md) diff --git a/docs/site/reference/client.md b/docs/site/reference/client.md index 90f37a3..8a7ee64 100644 --- a/docs/site/reference/client.md +++ b/docs/site/reference/client.md @@ -1,12 +1,17 @@ -# AvitoClient +# AvitoClient и AsyncAvitoClient `AvitoClient` — единственная публичная точка входа SDK. Он владеет конфигурацией, auth-provider и transport-слоем, а наружу отдаёт только доменные объекты. +`AsyncAvitoClient` предоставляет тот же фасад для async-кода. Он создаёт +loop-bound ресурсы в `async with`, закрывается через `aclose()` и возвращает +async-доменные объекты. + ## Контракт - `AvitoClient.from_env()` — основной путь для конфигурации из окружения. +- `AsyncAvitoClient.from_env()` — async-аналог; использовать только через `async with`. - `AvitoClient(client_id=..., client_secret=...)` — короткий явный путь для OAuth credentials. - `AvitoClient(AvitoSettings(...))` — полный путь для расширенной конфигурации. - Клиент поддерживает context manager и закрывает внутренние HTTP-клиенты в `close()`. @@ -17,6 +22,10 @@ ::: avito.AvitoClient +::: avito.AsyncAvitoClient + ## Безопасная диагностика ::: avito.AvitoClient.debug_info + +::: avito.AsyncAvitoClient.debug_info diff --git a/docs/site/reference/pagination.md b/docs/site/reference/pagination.md index 7052780..b9dd1b0 100644 --- a/docs/site/reference/pagination.md +++ b/docs/site/reference/pagination.md @@ -5,3 +5,9 @@ итерации. `materialize()` загружает все страницы и возвращает обычный список. ::: avito.PaginatedList + +`AsyncPaginatedList[T]` — async-аналог. Он не наследуется от `list`, читается через +`async for` или `await materialize()` и не поддерживает конкурентную итерацию одного +экземпляра из нескольких coroutine. + +::: avito.AsyncPaginatedList diff --git a/docs/site/reference/testing.md b/docs/site/reference/testing.md index 6335d05..66b7b6f 100644 --- a/docs/site/reference/testing.md +++ b/docs/site/reference/testing.md @@ -14,6 +14,10 @@ - `route_sequence()` задаёт последовательность ответов для retry и stateful-сценариев. - `FakeTransport.as_client()` создаёт полностью инициализированный `AvitoClient` поверх fake transport без реального HTTP. +- `AsyncFakeTransport.as_client()` создаёт полностью инициализированный + `AsyncAvitoClient` поверх `httpx.AsyncClient` и `httpx.MockTransport`. +- `FanoutPeakRecorder` измеряет пик одновременно выполняющихся async fake-запросов + в consumer-side тестах агрегаторов. - `RecordedRequest` позволяет проверять method, path, query params, headers и JSON body. Пользовательские тесты должны работать через публичные утилиты `avito.testing`, diff --git a/mkdocs.yml b/mkdocs.yml index d3f99e9..9c9c82b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: avito-py -site_description: Синхронный Python SDK для Avito API +site_description: Sync/async Python SDK для Avito API site_url: https://18studio.github.io/avito_python_api/ repo_url: https://github.com/p141592/avito_python_api repo_name: p141592/avito_python_api diff --git a/poetry.lock b/poetry.lock index 9859c35..11647bc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1531,6 +1531,25 @@ pygments = ">=2.7.2" [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "0.24.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b"}, + {file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"}, +] + +[package.dependencies] +pytest = ">=8.2,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1927,4 +1946,4 @@ bracex = ">=2.1.1" [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "8cd17d6641427b87f9ec0e46cf1fd6d4e01dc2363880555854699ee5dd92695b" +content-hash = "10ea6c90d6302eea53b556e2c4e67d4990bda7fcf9a19b0bdd1df35ac62d4deb" diff --git a/pyproject.toml b/pyproject.toml index 52da432..f4ed994 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "avito-py" -version = "2.0.0" +version = "2.1.0" description = "SDK для разработки инструментов на базе Avito API" authors = ["Nikolay Baryshnikov "] packages=[ @@ -36,6 +36,7 @@ ruff = "^0.12.12" respx = "^0.22.0" libcst = "^1.8.6" bowler = "^0.9.0" +pytest-asyncio = "^0.24" [tool.poetry.group.docs.dependencies] mkdocs-material = "^9.5" @@ -53,6 +54,8 @@ pydocstyle = { version = ">=6.3", extras = ["toml"] } [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["scripts"] +asyncio_mode = "strict" +asyncio_default_fixture_loop_scope = "function" markers = [ "live: требует доступа к сети; запускать с --live", ] diff --git a/scripts/lint_architecture.py b/scripts/lint_architecture.py index 3ab34b3..06b95ab 100644 --- a/scripts/lint_architecture.py +++ b/scripts/lint_architecture.py @@ -388,70 +388,75 @@ def _lint_public_domain_methods( for domain in API_DOMAINS: if domain in allowlisted_domains: continue - path = root / "avito" / domain / "domain.py" - if not path.exists(): - continue - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - for class_node in _public_classes(tree): - for method_node in _public_methods(class_node): - if (domain, class_node.name, method_node.name) in APPROVED_PUBLIC_WRAPPERS: - continue - method_label = f"{class_node.name}.{method_node.name}" - for parameter in _optional_positional_parameters(method_node): - errors.append( - ArchitectureLintError( - code="ARCH_PUBLIC_OPTIONAL_POSITIONAL", - message=( - f"Public API method `{method_label}` содержит optional " - f"positional parameter `{parameter.arg}`; сделайте его keyword-only." - ), - path=_relative_path(path, root), - line=parameter.lineno, + for path in ( + root / "avito" / domain / "domain.py", + root / "avito" / domain / "async_domain.py", + ): + if not path.exists(): + continue + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for class_node in _public_classes(tree): + for method_node in _public_methods(class_node): + if (domain, class_node.name, method_node.name) in APPROVED_PUBLIC_WRAPPERS: + continue + method_label = f"{class_node.name}.{method_node.name}" + for parameter in _optional_positional_parameters(method_node): + errors.append( + ArchitectureLintError( + code="ARCH_PUBLIC_OPTIONAL_POSITIONAL", + message=( + f"Public API method `{method_label}` содержит optional " + f"positional parameter `{parameter.arg}`; сделайте его keyword-only." + ), + path=_relative_path(path, root), + line=parameter.lineno, + ) ) - ) - for parameter in _date_like_string_parameters(method_node): - errors.append( - ArchitectureLintError( - code="ARCH_PUBLIC_DATE_STRING_UNVALIDATED", - message=( - f"Public API method `{method_label}` принимает date-like string " - f"parameter `{parameter.arg}` без явного validation/serialization helper." - ), - path=_relative_path(path, root), - line=parameter.lineno, + for parameter in _date_like_string_parameters(method_node): + errors.append( + ArchitectureLintError( + code="ARCH_PUBLIC_DATE_STRING_UNVALIDATED", + message=( + f"Public API method `{method_label}` принимает date-like string " + f"parameter `{parameter.arg}` без явного validation/serialization helper." + ), + path=_relative_path(path, root), + line=parameter.lineno, + ) ) - ) - if not _has_swagger_operation(method_node): - errors.append( - ArchitectureLintError( - code="ARCH_PUBLIC_METHOD_UNBOUND", - message=f"Public API method `{method_label}` без swagger_operation.", - path=_relative_path(path, root), - line=method_node.lineno, + if not _has_swagger_operation(method_node): + errors.append( + ArchitectureLintError( + code="ARCH_PUBLIC_METHOD_UNBOUND", + message=f"Public API method `{method_label}` без swagger_operation.", + path=_relative_path(path, root), + line=method_node.lineno, + ) ) - ) - if not _method_uses_operation_executor(method_node): - errors.append( - ArchitectureLintError( - code="ARCH_PUBLIC_METHOD_NO_OPERATION_SPEC", - message=f"Public API method `{method_label}` не исполняется через OperationSpec.", - path=_relative_path(path, root), - line=method_node.lineno, + if not _method_uses_operation_executor(method_node): + errors.append( + ArchitectureLintError( + code="ARCH_PUBLIC_METHOD_NO_OPERATION_SPEC", + message=f"Public API method `{method_label}` не исполняется через OperationSpec.", + path=_relative_path(path, root), + line=method_node.lineno, + ) ) - ) - if _annotation_is_forbidden_public_return(method_node.returns): - errors.append( - ArchitectureLintError( - code="ARCH_PUBLIC_RETURN_RAW", - message=f"Public API method `{method_label}` возвращает dict или Any.", - path=_relative_path(path, root), - line=method_node.lineno, + if _annotation_is_forbidden_public_return(method_node.returns): + errors.append( + ArchitectureLintError( + code="ARCH_PUBLIC_RETURN_RAW", + message=f"Public API method `{method_label}` возвращает dict или Any.", + path=_relative_path(path, root), + line=method_node.lineno, + ) ) - ) return tuple(errors) -def _optional_positional_parameters(method_node: ast.FunctionDef) -> tuple[ast.arg, ...]: +def _optional_positional_parameters( + method_node: ast.FunctionDef | ast.AsyncFunctionDef, +) -> tuple[ast.arg, ...]: positional_args = tuple(method_node.args.posonlyargs + method_node.args.args) positional_args = tuple(arg for arg in positional_args if arg.arg != "self") default_count = len(method_node.args.defaults) @@ -460,7 +465,9 @@ def _optional_positional_parameters(method_node: ast.FunctionDef) -> tuple[ast.a return positional_args[-default_count:] -def _date_like_string_parameters(method_node: ast.FunctionDef) -> tuple[ast.arg, ...]: +def _date_like_string_parameters( + method_node: ast.FunctionDef | ast.AsyncFunctionDef, +) -> tuple[ast.arg, ...]: if _method_uses_date_validation_helper(method_node): return () parameters = tuple( @@ -473,7 +480,7 @@ def _date_like_string_parameters(method_node: ast.FunctionDef) -> tuple[ast.arg, return tuple(parameter for parameter in parameters if _is_unvalidated_date_string(parameter)) -def _method_uses_date_validation_helper(method_node: ast.FunctionDef) -> bool: +def _method_uses_date_validation_helper(method_node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: for node in ast.walk(method_node): if not isinstance(node, ast.Call): continue @@ -577,7 +584,9 @@ def _collect_domain_classes(root: Path, domain: str) -> Mapping[str, ClassInfo]: name=node.name, bases=frozenset(_base_name(base) for base in node.bases), methods=frozenset( - item.name for item in node.body if isinstance(item, ast.FunctionDef) + item.name + for item in node.body + if isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef) ), path=path, line=node.lineno, @@ -600,17 +609,17 @@ def _public_classes(tree: ast.Module) -> Iterable[ast.ClassDef]: yield node -def _public_methods(class_node: ast.ClassDef) -> Iterable[ast.FunctionDef]: +def _public_methods(class_node: ast.ClassDef) -> Iterable[ast.FunctionDef | ast.AsyncFunctionDef]: for node in class_node.body: - if isinstance(node, ast.FunctionDef) and not node.name.startswith("_"): + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and not node.name.startswith("_"): yield node -def _has_swagger_operation(method_node: ast.FunctionDef) -> bool: +def _has_swagger_operation(method_node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: return any(_decorator_name(decorator) == "swagger_operation" for decorator in method_node.decorator_list) -def _method_uses_operation_executor(method_node: ast.FunctionDef) -> bool: +def _method_uses_operation_executor(method_node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: for node in ast.walk(method_node): if not isinstance(node, ast.Call): continue diff --git a/scripts/lint_async_parity.py b/scripts/lint_async_parity.py new file mode 100644 index 0000000..abfda78 --- /dev/null +++ b/scripts/lint_async_parity.py @@ -0,0 +1,96 @@ +"""Static async parity lint for ported async domain classes.""" + +from __future__ import annotations + +import importlib +import inspect +import pkgutil +from collections.abc import Iterator + +from avito.core.domain import AsyncDomainObject + +EXCLUDED_PACKAGES = {"auth", "core", "summary", "testing"} + + +def iter_async_classes() -> Iterator[type[AsyncDomainObject]]: + """Yield all public async domain classes in stable order.""" + + import avito + + package_paths = getattr(avito, "__path__", ()) + classes: list[type[AsyncDomainObject]] = [] + for info in pkgutil.iter_modules(package_paths): + if not info.ispkg or info.name in EXCLUDED_PACKAGES: + continue + module_name = f"avito.{info.name}.async_domain" + try: + module = importlib.import_module(module_name) + except ModuleNotFoundError: + continue + for _, value in inspect.getmembers(module, inspect.isclass): + if value.__module__ != module.__name__: + continue + if value is AsyncDomainObject: + continue + if issubclass(value, AsyncDomainObject): + classes.append(value) + yield from sorted(classes, key=lambda cls: (cls.__module__, cls.__name__)) + + +def main() -> int: + """Run parity lint for currently ported async classes.""" + + errors: list[str] = [] + for async_class in iter_async_classes(): + sync_name = async_class.__name__.removeprefix("Async") + package = async_class.__module__.split(".")[1] + sync_module = importlib.import_module(f"avito.{package}.domain") + sync_class = getattr(sync_module, sync_name, None) + if sync_class is None: + errors.append(f"{async_class.__module__}.{async_class.__name__}: sync class missing") + continue + for attr in ("__swagger_domain__", "__sdk_factory__", "__sdk_factory_args__"): + if getattr(async_class, attr, None) != getattr(sync_class, attr, None): + errors.append(f"{async_class.__name__}: metadata mismatch for {attr}") + sync_methods = _public_methods(sync_class) + async_methods = _public_methods(async_class) + if set(sync_methods) != set(async_methods): + errors.append(f"{async_class.__name__}: public method set mismatch") + continue + for name, async_method in async_methods.items(): + if not inspect.iscoroutinefunction(async_method): + errors.append(f"{async_class.__name__}.{name}: must be async def") + sync_binding = getattr(sync_methods[name], "__swagger_binding__", None) + async_binding = getattr(async_method, "__swagger_binding__", None) + if sync_binding is None or async_binding is None: + errors.append(f"{async_class.__name__}.{name}: missing swagger binding") + continue + sync_key = ( + sync_binding.spec, + sync_binding.method, + sync_binding.path, + sync_binding.operation_id, + ) + async_key = ( + async_binding.spec, + async_binding.method, + async_binding.path, + async_binding.operation_id, + ) + if sync_key != async_key or async_binding.variant != "async": + errors.append(f"{async_class.__name__}.{name}: swagger binding mismatch") + for error in errors: + print(error) + return 1 if errors else 0 + + +def _public_methods(cls: type[object]) -> dict[str, object]: + return { + name: value + for name, value in inspect.getmembers(cls, inspect.isfunction) + if not name.startswith("_") and value.__qualname__.startswith(f"{cls.__name__}.") + } + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lint_docstrings.py b/scripts/lint_docstrings.py index cc4533b..82f21bc 100644 --- a/scripts/lint_docstrings.py +++ b/scripts/lint_docstrings.py @@ -52,11 +52,17 @@ def lint_docstrings(root: Path = Path(".")) -> tuple[DocstringLintError, ...]: normalized_root = root.resolve() errors: list[DocstringLintError] = [] - for path in sorted((normalized_root / "avito").glob("*/domain.py")): + paths = [ + *sorted((normalized_root / "avito").glob("*/domain.py")), + *sorted((normalized_root / "avito").glob("*/async_domain.py")), + ] + for path in paths: tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) for class_node in (node for node in tree.body if isinstance(node, ast.ClassDef)): for function_node in ( - node for node in class_node.body if isinstance(node, ast.FunctionDef) + node + for node in class_node.body + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) ): docstring = ast.get_docstring(function_node) or "" for fragment in GENERIC_DOCSTRING_FRAGMENTS: diff --git a/tests/async_fake_transport.py b/tests/async_fake_transport.py new file mode 100644 index 0000000..95683c1 --- /dev/null +++ b/tests/async_fake_transport.py @@ -0,0 +1,7 @@ +"""Compatibility re-export for async fake transport tests.""" + +from __future__ import annotations + +from avito.testing.async_fake_transport import AsyncFakeTransport + +__all__ = ("AsyncFakeTransport",) diff --git a/tests/auth/test_async_provider.py b/tests/auth/test_async_provider.py new file mode 100644 index 0000000..e292e03 --- /dev/null +++ b/tests/auth/test_async_provider.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import asyncio +import inspect +from datetime import UTC, datetime, timedelta + +import httpx +import pytest + +from avito.auth.async_provider import AsyncAuthProvider +from avito.auth.async_token_client import AsyncTokenClient +from avito.auth.models import AccessToken, TokenResponse +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings + + +@pytest.mark.asyncio +async def test_invalidate_token_is_sync_and_idempotent() -> None: + async def fetcher(settings: AuthSettings) -> TokenResponse: + return TokenResponse( + access_token=AccessToken( + value="token", + expires_at=datetime.now(UTC) + timedelta(hours=1), + ) + ) + + provider = AsyncAuthProvider( + AuthSettings(client_id="id", client_secret="secret"), + token_fetcher=fetcher, + ) + assert not inspect.iscoroutinefunction(provider.invalidate_token) + + assert await provider.get_access_token() == "token" + provider.invalidate_token() + provider.invalidate_token() + + assert await provider.get_access_token() == "token" + + +@pytest.mark.asyncio +async def test_autoteka_concurrent_first_touch_single_token_request() -> None: + token_requests = 0 + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal token_requests + assert request.url.path == "/autoteka/token" + token_requests += 1 + await asyncio.sleep(0) + return httpx.Response( + 200, + json={"access_token": "autoteka-token", "expires_in": 3600, "token_type": "Bearer"}, + ) + + settings = AuthSettings( + client_id="main-client-id", + client_secret="main-client-secret", + autoteka_client_id="autoteka-client-id", + autoteka_client_secret="autoteka-client-secret", + autoteka_scope="autoteka:read", + ) + sdk_settings = AvitoSettings(auth=settings) + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(handler), + base_url="https://api.avito.ru", + ) + provider = AsyncAuthProvider( + settings, + autoteka_token_client=AsyncTokenClient( + settings, + token_url=settings.autoteka_token_url, + client=http_client, + sdk_settings=sdk_settings, + ), + ) + + tokens = await asyncio.gather(*(provider.get_autoteka_access_token() for _ in range(20))) + + assert tokens == ["autoteka-token"] * 20 + assert token_requests == 1 + await provider.aclose() diff --git a/tests/contracts/test_async_swagger_contracts.py b/tests/contracts/test_async_swagger_contracts.py new file mode 100644 index 0000000..6312005 --- /dev/null +++ b/tests/contracts/test_async_swagger_contracts.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import warnings +from collections.abc import Iterator + +import pytest + +from avito.core.deprecation import _WARNED_SYMBOLS +from avito.core.exceptions import ( + AuthenticationError, + AuthorizationError, + ConflictError, + RateLimitError, + UpstreamApiError, + ValidationError, +) +from avito.core.swagger_discovery import discover_swagger_bindings +from avito.core.swagger_registry import SwaggerOperation, load_swagger_registry +from avito.testing import ( + AsyncSwaggerFakeTransport, + error_payload, + generate_schema_value, + validate_schema_value, +) + +_REGISTRY = load_swagger_registry() +_DISCOVERY = discover_swagger_bindings(registry=_REGISTRY) +_BINDINGS = tuple(binding for binding in _DISCOVERY.bindings if binding.variant == "async") +_BINDING_OPERATION_BY_KEY = {operation.key: operation for operation in _REGISTRY.operations} + + +def _binding_id(binding: object) -> str: + operation_key = getattr(binding, "operation_key", None) + sdk_method = getattr(binding, "sdk_method", repr(binding)) + return operation_key or sdk_method + + +def _expected_exception_type(status_code: int, domain: str) -> type[Exception]: + if domain == "auth": + return AuthenticationError + if status_code == 400: + return ValidationError + if status_code == 401: + return AuthenticationError + if status_code == 403: + return AuthorizationError + if status_code == 409: + return ConflictError + if status_code == 422: + return ValidationError + if status_code == 429: + return RateLimitError + return UpstreamApiError + + +def _error_status_cases() -> tuple[tuple[SwaggerOperation, object, int, type[Exception]], ...]: + cases: list[tuple[SwaggerOperation, object, int, type[Exception]]] = [] + binding_by_operation = {binding.operation_key: binding for binding in _BINDINGS} + for operation in _REGISTRY.operations: + binding = binding_by_operation[operation.key] + for response in operation.error_responses: + if response.status_code.isdigit(): + status_code = int(response.status_code) + cases.append( + ( + operation, + binding, + status_code, + _expected_exception_type(status_code, binding.domain), + ) + ) + return tuple(cases) + + +def _error_status_id(case: tuple[SwaggerOperation, object, int, type[Exception]]) -> str: + operation, _binding, status_code, _expected_error = case + return f"{operation.key} {status_code}" + + +def test_async_swagger_bindings_are_discoverable_for_ported_domains() -> None: + assert {binding.class_name for binding in _BINDINGS} == { + "AsyncAccount", + "AsyncAccountHierarchy", + "AsyncAd", + "AsyncAdPromotion", + "AsyncAdStats", + "AsyncAlternateTokenClient", + "AsyncAutotekaMonitoring", + "AsyncAutotekaReport", + "AsyncAutotekaScoring", + "AsyncAutotekaValuation", + "AsyncAutotekaVehicle", + "AsyncAutoloadArchive", + "AsyncAutoloadProfile", + "AsyncAutoloadReport", + "AsyncCallTrackingCall", + "AsyncChat", + "AsyncChatMedia", + "AsyncChatMessage", + "AsyncChatWebhook", + "AsyncCpaArchive", + "AsyncCpaAuction", + "AsyncCpaCall", + "AsyncCpaChat", + "AsyncCpaLead", + "AsyncDeliveryOrder", + "AsyncDeliveryTask", + "AsyncAutostrategyCampaign", + "AsyncBbipPromotion", + "AsyncApplication", + "AsyncJobDictionary", + "AsyncJobWebhook", + "AsyncResume", + "AsyncVacancy", + "AsyncRatingProfile", + "AsyncRealtyAnalyticsReport", + "AsyncRealtyBooking", + "AsyncRealtyListing", + "AsyncRealtyPricing", + "AsyncOrder", + "AsyncOrderLabel", + "AsyncReview", + "AsyncReviewAnswer", + "AsyncSandboxDelivery", + "AsyncSpecialOfferCampaign", + "AsyncStock", + "AsyncPromotionOrder", + "AsyncTariff", + "AsyncTargetActionPricing", + "AsyncTokenClient", + "AsyncTrxPromotion", + } + assert len(_BINDINGS) == 204 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("binding", _BINDINGS, ids=_binding_id) +async def test_async_swagger_fake_transport_invokes_every_discovered_binding(binding: object) -> None: + fake = AsyncSwaggerFakeTransport(registry=_REGISTRY) + fake.add_success_operation(binding.operation_key or "") + + warning_context: Iterator[object] + if binding.deprecated: + _WARNED_SYMBOLS.clear() + warning_context = pytest.warns(DeprecationWarning) + else: + warning_context = warnings.catch_warnings() + with warning_context: + if not binding.deprecated: + warnings.simplefilter("ignore", DeprecationWarning) + await fake.invoke_binding(binding) + + assert fake.count() >= 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("binding", _BINDINGS, ids=_binding_id) +async def test_async_swagger_fake_transport_request_body_matches_swagger_schema( + binding: object, +) -> None: + if binding.operation_key is None: + pytest.fail(f"{binding.sdk_method}: binding без operation_key") + operation = _BINDING_OPERATION_BY_KEY[binding.operation_key] + if ( + operation.request_body is None + or "application/json" not in operation.request_body.content_types + ): + return + + fake = AsyncSwaggerFakeTransport(registry=_REGISTRY) + fake.add_success_operation(binding.operation_key) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + await fake.invoke_binding(binding) + + request = fake.last() + if request.json_body is None: + assert not operation.request_body.required + return + assert operation.request_body.schema is not None + validate_schema_value( + request.json_body, + operation.request_body.schema, + path=f"{operation.key}.requestBody", + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("binding", _BINDINGS, ids=_binding_id) +async def test_async_swagger_success_response_models_accept_swagger_schema_payload( + binding: object, +) -> None: + if binding.operation_key is None: + pytest.fail(f"{binding.sdk_method}: binding без operation_key") + operation = _BINDING_OPERATION_BY_KEY[binding.operation_key] + response = next( + ( + item + for item in operation.success_responses + if "application/json" in item.content_types and item.schema is not None + ), + None, + ) + if response is None: + return + + payload = generate_schema_value(response.schema) + validate_schema_value(payload, response.schema, path=f"{operation.key}.{response.status_code}") + fake = AsyncSwaggerFakeTransport(registry=_REGISTRY) + fake.add_operation(operation.key, payload, status_code=int(response.status_code)) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + result = await fake.invoke_binding(binding) + + assert not isinstance(result, dict) + + +def test_async_swagger_error_contract_coverage_matches_numeric_error_responses() -> None: + cases = _error_status_cases() + expected_count = sum( + 1 + for operation in _REGISTRY.operations + for response in operation.error_responses + if response.status_code.isdigit() + ) + + assert len(cases) == expected_count == 639 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("case", _error_status_cases(), ids=_error_status_id) +async def test_async_swagger_fake_transport_maps_every_declared_error_status( + case: tuple[SwaggerOperation, object, int, type[Exception]], +) -> None: + operation, binding, status_code, expected_error = case + fake = AsyncSwaggerFakeTransport(registry=_REGISTRY) + fake.add_operation(operation.key, error_payload(status_code), status_code=status_code) + + with pytest.raises(expected_error) as exc_info: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + await fake.invoke_binding(binding) + + assert exc_info.value.args[0] == f"Ошибка {status_code}" diff --git a/tests/contracts/test_swagger_contracts.py b/tests/contracts/test_swagger_contracts.py index 3511ed7..1afeea1 100644 --- a/tests/contracts/test_swagger_contracts.py +++ b/tests/contracts/test_swagger_contracts.py @@ -30,7 +30,7 @@ _REGISTRY = load_swagger_registry() _DISCOVERY = discover_swagger_bindings(registry=_REGISTRY) -_BINDINGS = _DISCOVERY.bindings +_BINDINGS = tuple(binding for binding in _DISCOVERY.bindings if binding.variant == "sync") _BINDING_BY_OPERATION = _DISCOVERY.canonical_map _BINDING_OPERATION_BY_KEY = {operation.key: operation for operation in _REGISTRY.operations} @@ -90,7 +90,11 @@ def _expected_exception_type( def _binding(registry: SwaggerRegistry, operation_key: str) -> DiscoveredSwaggerBinding: discovery = discover_swagger_bindings(registry=registry) - matches = [binding for binding in discovery.bindings if binding.operation_key == operation_key] + matches = [ + binding + for binding in discovery.bindings + if binding.operation_key == operation_key and binding.variant == "sync" + ] assert len(matches) == 1 return matches[0] diff --git a/tests/core/test_async_client_lifecycle.py b/tests/core/test_async_client_lifecycle.py new file mode 100644 index 0000000..b18e194 --- /dev/null +++ b/tests/core/test_async_client_lifecycle.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import pytest + +from avito.async_client import AsyncAvitoClient +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core.exceptions import ClientClosedError + + +def _settings() -> AvitoSettings: + return AvitoSettings(auth=AuthSettings(client_id="id", client_secret="secret")) + + +def test_access_before_aenter_raises() -> None: + client = AsyncAvitoClient(_settings()) + + with pytest.raises(RuntimeError): + client.debug_info() + + +@pytest.mark.asyncio +async def test_aclose_is_idempotent_and_closes_public_methods() -> None: + client = AsyncAvitoClient(_settings()) + await client.__aenter__() + + assert client.debug_info().requires_auth is True + await client.aclose() + await client.aclose() + + with pytest.raises(ClientClosedError): + client.auth() + diff --git a/tests/core/test_async_executor.py b/tests/core/test_async_executor.py new file mode 100644 index 0000000..8e1bb80 --- /dev/null +++ b/tests/core/test_async_executor.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import httpx +import pytest + +from avito.core.domain import AsyncDomainObject +from avito.core.operations import AsyncOperationExecutor, OperationExecutor, OperationSpec +from avito.core.swagger import swagger_operation +from avito.core.types import RequestContext, RetryOverride +from avito.testing import AsyncFakeTransport + +BINARY_SPEC: OperationSpec[object] = OperationSpec( + name="test.binary.download", + method="GET", + path="/binary/{item_id}", + response_kind="binary", +) + + +class _TestBinaryDomain(AsyncDomainObject): + @swagger_operation("GET", "/binary/{item_id}", spec="Test.json", variant="async") + async def download(self, item_id: int) -> object: + return await self._execute(BINARY_SPEC, path_params={"item_id": item_id}) + + +class BinaryTransport: + def __init__(self) -> None: + self.contexts: list[RequestContext] = [] + + async def request(self, *args: object, **kwargs: object) -> httpx.Response: + self.contexts.append(kwargs["context"]) + request = httpx.Request("GET", "https://api.avito.ru/file") + return httpx.Response( + 200, + content=b"file", + headers={"content-disposition": 'attachment; filename="label.pdf"'}, + request=request, + ) + + async def request_json(self, *args: object, **kwargs: object) -> object: + self.contexts.append(kwargs["context"]) + return {} + + +class SyncRetryTransport: + def __init__(self) -> None: + self.contexts: list[RequestContext] = [] + + def request(self, *args: object, **kwargs: object) -> httpx.Response: + self.contexts.append(kwargs["context"]) + request = httpx.Request("GET", "https://api.avito.ru/items") + return httpx.Response(204, request=request) + + def request_json(self, *args: object, **kwargs: object) -> object: + self.contexts.append(kwargs["context"]) + return {} + + +@pytest.mark.asyncio +async def test_binary_branch_uses_async_request() -> None: + spec: OperationSpec[object] = OperationSpec( + name="orders.label.download", + method="GET", + path="/file", + response_kind="binary", + ) + + result = await AsyncOperationExecutor(BinaryTransport()).execute(spec) + + assert result.content == b"file" + assert result.filename == "label.pdf" + + +@pytest.mark.asyncio +async def test_async_executor_full_binary_pipeline() -> None: + fake = AsyncFakeTransport().add( + "GET", + "/binary/42", + httpx.Response( + 200, + content=b"full-pipeline", + headers={ + "content-type": "application/pdf", + "content-disposition": 'attachment; filename="full.pdf"', + }, + ), + ) + transport = fake.build() + + result = await _TestBinaryDomain(transport).download(42) + + assert result.content == b"full-pipeline" + assert result.content_type == "application/pdf" + assert result.filename == "full.pdf" + assert result.status_code == 200 + assert result.headers["content-type"] == "application/pdf" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_operation_transport_protocol_uses_async_methods() -> None: + response = await BinaryTransport().request("GET", "/x", context=RequestContext("x")) + + assert response.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("retry", "spec_retry", "allow_retry", "retry_disabled"), + [ + (None, "enabled", True, False), + ("disabled", "enabled", False, True), + ("enabled", "default", True, False), + ], +) +async def test_executor_retry_resolution_matches_sync( + retry: RetryOverride | None, + spec_retry: RetryOverride, + allow_retry: bool, + retry_disabled: bool, +) -> None: + spec: OperationSpec[object] = OperationSpec( + name="items.list", + method="GET", + path="/items", + retry_mode=spec_retry, + ) + sync_transport = SyncRetryTransport() + async_transport = BinaryTransport() + + OperationExecutor(sync_transport).execute(spec, retry=retry) + await AsyncOperationExecutor(async_transport).execute(spec, retry=retry) + + assert sync_transport.contexts[0].allow_retry == allow_retry + assert async_transport.contexts[0].allow_retry == allow_retry + assert sync_transport.contexts[0].retry_disabled == retry_disabled + assert async_transport.contexts[0].retry_disabled == retry_disabled diff --git a/tests/core/test_async_pagination.py b/tests/core/test_async_pagination.py new file mode 100644 index 0000000..4528121 --- /dev/null +++ b/tests/core/test_async_pagination.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from collections.abc import AsyncGenerator +from typing import cast + +import pytest + +from avito.core.async_pagination import AsyncPaginatedList +from avito.core.types import JsonPage + + +@pytest.mark.asyncio +async def test_async_paginated_list_materializes_pages() -> None: + pages = { + 1: JsonPage(items=[1, 2], page=1, per_page=2, total=3), + 2: JsonPage(items=[3], page=2, per_page=2, total=3), + } + + async def fetch(page: int | None, cursor: str | None) -> JsonPage[int]: + assert cursor is None + return pages[page or 1] + + items = AsyncPaginatedList(fetch, first_page=pages[1]) + + assert items.loaded_count == 2 + assert await items.materialize() == [1, 2, 3] + assert items.is_materialized is True + + +@pytest.mark.asyncio +async def test_concurrent_aiter_raises_runtime_error() -> None: + async def fetch(page: int | None, cursor: str | None) -> JsonPage[int]: + return JsonPage(items=[1], page=page, per_page=1, total=1) + + items = AsyncPaginatedList(fetch) + iterator = items.__aiter__() + + with pytest.raises(RuntimeError): + items.__aiter__() + + assert await anext(iterator) == 1 + await cast(AsyncGenerator[int, None], iterator).aclose() diff --git a/tests/core/test_async_transport.py b/tests/core/test_async_transport.py new file mode 100644 index 0000000..cbea717 --- /dev/null +++ b/tests/core/test_async_transport.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import httpx +import pytest + +from avito.config import AvitoSettings +from avito.core.async_transport import AsyncTransport +from avito.core.retries import RetryPolicy +from avito.core.types import RequestContext +from avito.testing import AsyncFakeTransport + + +@pytest.mark.asyncio +async def test_async_transport_sends_authorization_and_retries_after_401() -> None: + fake = ( + AsyncFakeTransport() + .add_json("POST", "/token", {"access_token": "old", "expires_in": 3600}) + .add_json("POST", "/token", {"access_token": "new", "expires_in": 3600}) + .add_json("GET", "/core/v1/accounts/self", {"error": "expired"}, status_code=401) + .add_json("GET", "/core/v1/accounts/self", {"id": 7}) + ) + transport = fake.build(authenticated=True) + + payload = await transport.request_json( + "GET", + "/core/v1/accounts/self", + context=RequestContext("smoke"), + ) + + assert payload == {"id": 7} + assert fake.count(method="POST", path="/token") == 2 + assert fake.last(method="GET", path="/core/v1/accounts/self").headers["authorization"] == ( + "Bearer new" + ) + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_transport_retries_429_and_propagates_idempotency_key() -> None: + fake = ( + AsyncFakeTransport() + .add_json("POST", "/items", {"error": "limited"}, status_code=429) + .add_json("POST", "/items", {"ok": True}) + ) + transport = fake.build( + retry_policy=RetryPolicy( + max_attempts=2, + backoff_factor=0, + retryable_methods=("POST",), + ) + ) + + payload = await transport.request_json( + "POST", + "/items", + context=RequestContext("items.create"), + json_body={"title": "x"}, + idempotency_key="idem-1", + ) + + assert payload == {"ok": True} + assert fake.count(method="POST", path="/items") == 2 + assert fake.last(method="POST", path="/items").headers["idempotency-key"] == "idem-1" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_transport_aclose_closes_passed_async_client() -> None: + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(lambda request: httpx.Response(200, request=request)) + ) + transport = AsyncTransport(AvitoSettings(), client=http_client) + + await transport.aclose() + await transport.aclose() + + assert http_client.is_closed is True + + +@pytest.mark.asyncio +async def test_download_binary_full_buffer_matches_sync_contract() -> None: + fake = AsyncFakeTransport().add( + "GET", + "/file", + __import__("httpx").Response( + 200, + content=b"payload", + headers={"content-type": "application/octet-stream"}, + ), + ) + transport = fake.build() + + result = await transport.download_binary("/file", context=RequestContext("binary")) + + assert result.content == b"payload" + assert result.content_type == "application/octet-stream" + await transport.aclose() diff --git a/tests/core/test_swagger_linter.py b/tests/core/test_swagger_linter.py index 59c6681..94ee076 100644 --- a/tests/core/test_swagger_linter.py +++ b/tests/core/test_swagger_linter.py @@ -3,13 +3,14 @@ from __future__ import annotations from avito.core.operations import OperationSpec -from avito.core.swagger_discovery import DiscoveredSwaggerBinding -from avito.core.swagger_linter import _validate_operation_json_body_models +from avito.core.swagger_discovery import DiscoveredSwaggerBinding, discover_swagger_bindings +from avito.core.swagger_linter import _validate_operation_json_body_models, lint_swagger_bindings from avito.core.swagger_registry import ( SwaggerOperation, SwaggerRequestBody, SwaggerResponse, SwaggerSchema, + load_swagger_registry, ) @@ -72,3 +73,18 @@ def test_validate_operation_json_body_models_requires_declared_models() -> None: "SWAGGER_CONTRACT_RESPONSE_MODEL_MISSING", "SWAGGER_CONTRACT_ERROR_MODEL_MISSING", } + + +def test_validate_factory_async_skips_auth_bindings() -> None: + registry = load_swagger_registry() + discovery = discover_swagger_bindings(registry=registry) + + errors = lint_swagger_bindings(registry, discovery, strict=True) + + assert not [ + error + for error in errors + if error.code.startswith("SWAGGER_BINDING_FACTORY") + and error.sdk_method is not None + and ".async_token_client." in error.sdk_method + ] diff --git a/tests/domains/accounts/test_accounts_async.py b/tests/domains/accounts/test_accounts_async.py new file mode 100644 index 0000000..52c6155 --- /dev/null +++ b/tests/domains/accounts/test_accounts_async.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from datetime import datetime + +import httpx +import pytest + +from avito.accounts import AsyncAccount, AsyncAccountHierarchy +from avito.async_client import AsyncAvitoClient +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core import AsyncPaginatedList +from avito.core.exceptions import AuthenticationError, RateLimitError, TransportError +from avito.core.retries import RetryPolicy +from avito.testing import AsyncFakeTransport +from avito.testing.fake_transport import RecordedRequest + + +def _profile_payload() -> dict[str, object]: + return {"id": 7, "name": "Иван", "email": "user@example.com", "phone": "+7999"} + + +def _balance_payload() -> dict[str, object]: + return {"user_id": 7, "balance": {"real": 150.5, "bonus": 20.0, "currency": "RUB"}} + + +def _operations_payload() -> dict[str, object]: + return { + "total": 1, + "operations": [ + { + "id": "op-1", + "created_at": "2025-01-02T12:00:00Z", + "amount": 120.0, + "type": "payment", + "status": "done", + } + ], + } + + +@pytest.mark.asyncio +async def test_async_account_domain_maps_profile_balance_and_operations() -> None: + def operations_history(request: RecordedRequest) -> httpx.Response: + assert request.json_body == { + "dateTimeFrom": "2025-01-01T00:00:00+00:00", + "dateTimeTo": "2025-01-31T00:00:00+00:00", + } + return httpx.Response(200, json=_operations_payload()) + + fake = ( + AsyncFakeTransport() + .add_json("GET", "/core/v1/accounts/self", _profile_payload()) + .add_json("GET", "/core/v1/accounts/7/balance/", _balance_payload()) + .add("POST", "/core/v1/accounts/operations_history/", operations_history) + ) + transport = fake.build() + account = AsyncAccount(transport, user_id=7) + + profile = await account.get_self() + balance = await account.get_balance() + history = await account.get_operations_history( + date_from=datetime.fromisoformat("2025-01-01T00:00:00+00:00"), + date_to=datetime.fromisoformat("2025-01-31T00:00:00+00:00"), + ) + + assert profile.user_id == 7 + assert balance.total == 170.5 + assert isinstance(history, AsyncPaginatedList) + assert history.loaded_count == 1 + materialized = await history.materialize() + assert len(materialized) == 1 + assert materialized[0].operation_type == "payment" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_account_balance_resolves_user_id_from_self_when_not_configured() -> None: + fake = ( + AsyncFakeTransport() + .add_json("GET", "/core/v1/accounts/self", {"id": 7}) + .add_json("GET", "/core/v1/accounts/7/balance/", {"user_id": 7, "balance": {"real": 150.0}}) + ) + transport = fake.build() + account = AsyncAccount(transport) + + balance = await account.get_balance() + + assert balance.user_id == 7 + assert [request.path for request in fake.requests] == [ + "/core/v1/accounts/self", + "/core/v1/accounts/7/balance/", + ] + await transport.aclose() + + +def test_async_account_balance_requires_keyword_user_id() -> None: + account = AsyncAccount(AsyncFakeTransport().build()) + + try: + account.get_balance(7) # type: ignore[misc] + except TypeError as error: + assert "positional" in str(error) + else: # pragma: no cover + raise AssertionError("get_balance accepted positional user_id") + + +@pytest.mark.asyncio +async def test_async_account_hierarchy_domain_maps_employees_phones_and_items() -> None: + def link_items(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"employeeId": 10, "itemIds": [1, 2]} + assert request.headers["idempotency-key"] == "link-1" + return httpx.Response(200, json={"success": True, "message": "linked"}) + + def list_items(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"employeeId": 10, "categoryId": 24} + return httpx.Response( + 200, + json={ + "items": [{"item_id": 1, "title": "Объявление", "status": "active", "price": 99}], + "total": 1, + }, + ) + + fake = ( + AsyncFakeTransport() + .add_json("GET", "/checkAhUserV1", {"user_id": 7, "is_active": True, "role": "manager"}) + .add_json( + "GET", + "/getEmployeesV1", + {"employees": [{"employee_id": 10, "user_id": 7, "name": "Пётр"}], "total": 1}, + ) + .add_json( + "GET", + "/listCompanyPhonesV1", + {"phones": [{"id": 1, "phone": "+7000", "comment": "Основной"}]}, + ) + .add("POST", "/linkItemsV1", link_items) + .add("POST", "/listItemsByEmployeeIdV1", list_items) + ) + transport = fake.build() + hierarchy = AsyncAccountHierarchy(transport, user_id=7) + + status = await hierarchy.get_status() + employees = await hierarchy.list_employees() + phones = await hierarchy.list_company_phones() + linked = await hierarchy.link_items(employee_id=10, item_ids=[1, 2], idempotency_key="link-1") + items = await hierarchy.list_items_by_employee(employee_id=10, category_id=24) + + assert status.is_active is True + assert employees.items[0].employee_id == 10 + assert phones.items[0].phone == "+7000" + assert linked.success is True + assert isinstance(items, AsyncPaginatedList) + assert items.loaded_count == 1 + materialized = await items.materialize() + assert materialized[0].title == "Объявление" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_account_factories_return_async_domains() -> None: + fake = ( + AsyncFakeTransport() + .add_json("GET", "/core/v1/accounts/self", _profile_payload()) + .add_json("GET", "/checkAhUserV1", {"user_id": 7, "is_active": True, "role": "manager"}) + ) + client = fake.as_client() + + account = client.account(user_id=7) + hierarchy = client.account_hierarchy(user_id=7) + + assert isinstance(account, AsyncAccount) + assert isinstance(hierarchy, AsyncAccountHierarchy) + assert (await account.get_self()).user_id == 7 + assert (await hierarchy.get_status()).is_active is True + await client.aclose() + + +@pytest.mark.asyncio +async def test_async_accounts_maps_401() -> None: + fake = AsyncFakeTransport().add_json( + "GET", + "/core/v1/accounts/self", + {"error": "unauthorized"}, + status_code=401, + ) + transport = fake.build() + + with pytest.raises(AuthenticationError): + await AsyncAccount(transport).get_self() + + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_accounts_maps_429() -> None: + fake = AsyncFakeTransport().add_json( + "GET", + "/core/v1/accounts/self", + {"error": "rate limit"}, + status_code=429, + ) + transport = fake.build(retry_policy=RetryPolicy(max_attempts=1)) + + with pytest.raises(RateLimitError): + await AsyncAccount(transport).get_self() + + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_accounts_maps_transport_error() -> None: + def raise_network_error(request: object) -> httpx.Response: + raise httpx.NetworkError("connection failed") + + fake = AsyncFakeTransport().add("GET", "/core/v1/accounts/self", raise_network_error) + transport = fake.build(retry_policy=RetryPolicy(max_attempts=1)) + + with pytest.raises(TransportError): + await AsyncAccount(transport).get_self() + + await transport.aclose() + + +def test_async_client_account_factories_require_entered_client() -> None: + client = AsyncAvitoClient( + AvitoSettings(auth=AuthSettings(client_id="id", client_secret="secret")) + ) + + with pytest.raises(RuntimeError): + client.account() + with pytest.raises(RuntimeError): + client.account_hierarchy() diff --git a/tests/domains/ads/test_ads_async.py b/tests/domains/ads/test_ads_async.py new file mode 100644 index 0000000..a403020 --- /dev/null +++ b/tests/domains/ads/test_ads_async.py @@ -0,0 +1,423 @@ +from __future__ import annotations + +import logging +from datetime import date, datetime + +import httpx +import pytest + +from avito.ads import ( + AsyncAd, + AsyncAdPromotion, + AsyncAdStats, + AsyncAutoloadArchive, + AsyncAutoloadProfile, + AsyncAutoloadReport, +) +from avito.ads.models import AdAnalyticsGrouping, AdSpendingsGrouping, ListingStatus +from avito.core import AsyncPaginatedList, ValidationError +from avito.testing import AsyncFakeTransport +from avito.testing.fake_transport import RecordedRequest + + +@pytest.mark.asyncio +async def test_async_ads_list_uses_lazy_pagination_with_first_page_reuse() -> None: + seen_pages: list[str] = [] + + def handler(request: RecordedRequest) -> httpx.Response: + assert request.params["user_id"] == "7" + assert request.params["status"] == "active" + assert request.params["per_page"] == "2" + page = request.params["page"] + seen_pages.append(page) + page_items = { + "1": [{"id": 101, "title": "Смартфон"}, {"id": 102, "title": "Ноутбук"}], + "2": [{"id": 103, "title": "Планшет"}, {"id": 104, "title": "Наушники"}], + "3": [{"id": 105, "title": "Камера"}], + } + return httpx.Response(200, json={"items": page_items[page], "total": 5}) + + fake = AsyncFakeTransport().add("GET", "/core/v1/items", handler) + transport = fake.build() + ad = AsyncAd(transport, user_id=7) + + items = await ad.list(status="active", page_size=2) + + assert isinstance(items, AsyncPaginatedList) + assert seen_pages == ["1"] + assert items.loaded_count == 2 + materialized = await items.materialize() + assert [item.title for item in materialized] == [ + "Смартфон", + "Ноутбук", + "Планшет", + "Наушники", + "Камера", + ] + assert seen_pages == ["1", "2", "3"] + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_ads_list_limit_is_total_cap_not_page_size() -> None: + seen_limits: list[str] = [] + seen_pages: list[str] = [] + + def handler(request: RecordedRequest) -> httpx.Response: + seen_limits.append(request.params["per_page"]) + page = request.params["page"] + seen_pages.append(page) + page_items = { + "1": [{"id": 101}, {"id": 102}], + "2": [{"id": 103}], + } + return httpx.Response(200, json={"items": page_items[page], "total": 5}) + + fake = AsyncFakeTransport().add("GET", "/core/v1/items", handler) + transport = fake.build() + ad = AsyncAd(transport, user_id=7) + + items = await ad.list(limit=3, page_size=2) + + assert [item.item_id for item in await items.materialize()] == [101, 102, 103] + assert seen_limits == ["2", "1"] + assert seen_pages == ["1", "2"] + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_ads_domain_covers_item_stats_spendings_and_promotion() -> None: + def update_price(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"price": 1500} + return httpx.Response(200, json={"item_id": 101, "price": 1500, "status": "updated"}) + + def apply_vas(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"vas_id": "xl"} + return httpx.Response(200, json={"success": True, "status": "applied"}) + + fake = ( + AsyncFakeTransport() + .add_json( + "GET", + "/core/v1/accounts/7/items/101/", + {"id": 101, "user_id": 7, "title": "Смартфон", "price": 1000, "status": "active"}, + ) + .add("POST", "/core/v1/items/101/update_price", update_price) + .add_json( + "POST", + "/stats/v1/accounts/7/items", + {"items": [{"item_id": 101, "views": 45, "contacts": 5, "favorites": 2}]}, + ) + .add_json( + "POST", + "/core/v1/accounts/7/calls/stats/", + {"items": [{"item_id": 101, "calls": 3, "answered_calls": 2, "missed_calls": 1}]}, + ) + .add_json( + "POST", + "/stats/v2/accounts/7/spendings", + {"items": [{"item_id": 101, "amount": 77.5, "service": "xl"}]}, + ) + .add("PUT", "/core/v1/accounts/7/items/101/vas", apply_vas) + ) + transport = fake.build() + ad = AsyncAd(transport, item_id=101, user_id=7) + stats = AsyncAdStats(transport, item_id=101, user_id=7) + promotion = AsyncAdPromotion(transport, item_id=101, user_id=7) + + item = await ad.get() + updated = await ad.update_price(price=1500) + item_stats = await stats.get_item_stats(date_from="2026-04-01", date_to="2026-04-02") + calls = await stats.get_calls_stats(date_from="2026-04-01", date_to="2026-04-02") + spendings = await stats.get_account_spendings( + date_from="2026-04-01", + date_to="2026-04-02", + spending_types=["promotion"], + grouping=AdSpendingsGrouping.DAY, + ) + applied = await promotion.apply_vas(vas_id="xl") + + assert item.title == "Смартфон" + assert updated.status == "updated" + assert item_stats.items[0].views == 45 + assert calls.items[0].answered_calls == 2 + assert spendings.total == 77.5 + assert applied.status == "applied" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_ad_stats_accept_datetime_date_and_iso_string_filters() -> None: + fake = AsyncFakeTransport().add_json( + "POST", + "/stats/v2/accounts/7/items", + {"items": [{"item_id": 101, "views": 10}]}, + ) + transport = fake.build() + stats = AsyncAdStats(transport, item_id=101, user_id=7) + started_at = datetime.fromisoformat("2026-04-18T00:00:00+03:00") + finished_at = date.fromisoformat("2026-04-19") + + await stats.get_item_analytics( + item_ids=[101], + date_from=started_at, + date_to=finished_at, + metrics=["views"], + grouping=AdAnalyticsGrouping.DAY, + limit=100, + offset=0, + ) + + assert fake.last(method="POST", path="/stats/v2/accounts/7/items").json_body == { + "dateFrom": "2026-04-18", + "dateTo": "2026-04-19", + "metrics": ["views"], + "grouping": "day", + "limit": 100, + "offset": 0, + } + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_ad_stats_reject_unknown_grouping_before_transport() -> None: + transport = AsyncFakeTransport().build() + stats = AsyncAdStats(transport, item_id=101, user_id=7) + + with pytest.raises(ValidationError, match="grouping"): + await stats.get_item_analytics( + date_from="2026-04-18", + date_to="2026-04-19", + metrics=["views"], + grouping="unknown", + limit=100, + offset=0, + ) + with pytest.raises(ValidationError, match="grouping"): + await stats.get_account_spendings( + date_from="2026-04-18", + date_to="2026-04-19", + spending_types=["promotion"], + grouping="totals", + ) + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_ad_mapper_reads_nested_listing_fields() -> None: + fake = AsyncFakeTransport().add_json( + "GET", + "/core/v1/accounts/7/items/101/", + { + "id": 101, + "userId": 7, + "title": "Смартфон", + "description": "Хорошее состояние", + "price": {"value": 1000}, + "status": {"value": "active"}, + "url": "https://www.avito.ru/item", + "category": {"name": "Телефоны"}, + "location": {"name": "Москва"}, + "publishedAt": "2026-04-18T09:00:00Z", + "updatedAt": "2026-04-19T10:00:00Z", + "isModerated": True, + "visible": True, + }, + ) + transport = fake.build() + + item = await AsyncAd(transport, item_id=101, user_id=7).get() + + assert item.status is ListingStatus.ACTIVE + assert item.price == 1000 + assert item.category == "Телефоны" + assert item.city == "Москва" + assert item.published_at is not None + assert item.updated_at is not None + assert item.is_moderated is True + assert item.is_visible is True + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_ads_unknown_enum_maps_to_unknown_and_warns_once( + caplog: pytest.LogCaptureFixture, +) -> None: + fake = AsyncFakeTransport().add_json( + "GET", + "/core/v1/accounts/7/items/101/", + { + "id": 101, + "user_id": 7, + "title": "Смартфон", + "price": 1000, + "status": "async-mystery-status", + }, + ) + transport = fake.build() + caplog.set_level(logging.WARNING, logger="avito.core.enums") + ad = AsyncAd(transport, item_id=101, user_id=7) + + first = await ad.get() + second = await ad.get() + + assert first.status is ListingStatus.UNKNOWN + assert second.status is ListingStatus.UNKNOWN + records = [ + record + for record in caplog.records + if getattr(record, "enum", None) == "ads.listing_status" + and getattr(record, "value", None) == "async-mystery-status" + ] + assert len(records) == 1 + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_autoload_profile_report_and_archive_map_payloads() -> None: + def save_profile(request: RecordedRequest) -> httpx.Response: + assert request.json_body == { + "autoload_enabled": True, + "report_email": "report@example.com", + "schedule": [{"rate": 10, "weekdays": [1], "time_slots": [2]}], + "feeds_data": [{"feed_name": "main", "feed_url": "https://example.com/feed.xml"}], + } + assert request.headers["idempotency-key"] == "profile-1" + return httpx.Response(200, json={"success": True, "message": "saved"}) + + fake = ( + AsyncFakeTransport() + .add_json("GET", "/autoload/v2/profile", {"user_id": 7, "is_enabled": True, "url": "x"}) + .add("POST", "/autoload/v2/profile", save_profile) + .add_json("POST", "/autoload/v1/upload", {"success": True, "report_id": 44}) + .add_json("GET", "/autoload/v1/user-docs/tree", {"items": [{"slug": "cars", "title": "Авто"}]}) + .add_json( + "GET", + "/autoload/v1/user-docs/node/cars/fields", + {"fields": [{"slug": "vin", "title": "VIN", "type": "string", "required": True}]}, + ) + .add_json("GET", "/autoload/v3/reports/44", {"id": 44, "status": "finished"}) + .add_json( + "GET", + "/autoload/v3/reports/last_completed_report", + {"id": 45, "status": "finished"}, + ) + .add_json( + "GET", + "/autoload/v2/reports/44/items", + {"items": [{"item_id": 101, "avito_id": 201, "status": "success", "title": "A"}]}, + ) + .add_json( + "GET", + "/autoload/v2/reports/44/items/fees", + {"fees": [{"item_id": 101, "amount": 55.5, "service": "xl"}]}, + ) + .add_json("GET", "/autoload/v2/items/ad_ids", {"items": [{"ad_id": 1, "avito_id": 2}]}) + .add_json("GET", "/autoload/v2/items/avito_ids", {"items": [{"ad_id": 1, "avito_id": 2}]}) + .add_json( + "GET", + "/autoload/v2/reports/items", + {"items": [{"item_id": 101, "avito_id": 201, "status": "success"}]}, + ) + .add_json("GET", "/autoload/v1/profile", {"user_id": 7, "enabled": True}) + .add_json("POST", "/autoload/v1/profile", {"success": True}) + .add_json("GET", "/autoload/v2/reports/last_completed_report", {"id": 46, "status": "finished"}) + .add_json("GET", "/autoload/v2/reports/44", {"id": 44, "status": "finished"}) + ) + transport = fake.build() + profile = AsyncAutoloadProfile(transport, user_id=7) + report = AsyncAutoloadReport(transport, report_id=44) + archive = AsyncAutoloadArchive(transport, report_id=44) + + settings = await profile.get() + saved = await profile.save( + is_enabled=True, + report_email="report@example.com", + schedule_rate=10, + feed_name="main", + feed_url="https://example.com/feed.xml", + schedule_weekdays=[1], + schedule_time_slots=[2], + idempotency_key="profile-1", + ) + upload = await profile.upload_by_url(url="https://example.com/feed.xml") + tree = await profile.get_tree() + fields = await profile.get_node_fields(node_slug="cars") + details = await report.get() + last = await report.get_last_completed() + items = await report.get_items() + fees = await report.get_fees() + ad_ids = await report.get_ad_ids_by_avito_ids(avito_ids=[2]) + avito_ids = await report.get_avito_ids_by_ad_ids(ad_ids=[1]) + info = await report.get_items_info(item_ids=[101]) + with pytest.warns(DeprecationWarning, match="AsyncAutoloadArchive.get_profile"): + archive_profile = await archive.get_profile() + with pytest.warns(DeprecationWarning, match="AsyncAutoloadArchive.save_profile"): + archive_saved = await archive.save_profile( + is_enabled=True, + upload_url="https://example.com/feed.xml", + report_email="report@example.com", + schedule_rate=10, + ) + with pytest.warns( + DeprecationWarning, + match="AsyncAutoloadArchive.get_last_completed_report", + ): + archive_last = await archive.get_last_completed_report() + with pytest.warns(DeprecationWarning, match="AsyncAutoloadArchive.get_report"): + archive_report = await archive.get_report() + + assert settings.user_id == 7 + assert saved.success is True + assert upload.report_id == 44 + assert tree.items[0].slug == "cars" + assert fields.items[0].required is True + assert details.report_id == 44 + assert last.report_id == 45 + assert items.items[0].item_id == 101 + assert fees.total == 55.5 + assert ad_ids.mappings == [(1, 2)] + assert avito_ids.mappings == [(1, 2)] + assert info.items[0].avito_id == 201 + assert archive_profile.user_id == 7 + assert archive_saved.success is True + assert archive_last.report_id == 46 + assert archive_report.report_id == 44 + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_autoload_report_list_uses_async_pagination() -> None: + seen_offsets: list[str] = [] + + def handler(request: RecordedRequest) -> httpx.Response: + seen_offsets.append(request.params["offset"]) + reports = { + "5": [{"id": 44, "status": "finished"}], + "6": [{"id": 45, "status": "started"}], + } + return httpx.Response(200, json={"reports": reports[request.params["offset"]], "total": 2}) + + fake = AsyncFakeTransport().add("GET", "/autoload/v2/reports", handler) + transport = fake.build() + + reports = await AsyncAutoloadReport(transport).list(limit=1, offset=5) + + assert isinstance(reports, AsyncPaginatedList) + assert reports.loaded_count == 1 + assert [report.report_id for report in await reports.materialize()] == [44, 45] + assert seen_offsets == ["5", "6"] + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_ads_factories_return_async_domains() -> None: + client = AsyncFakeTransport().as_client(user_id=7) + + assert isinstance(client.ad(101), AsyncAd) + assert isinstance(client.ad_stats(101), AsyncAdStats) + assert isinstance(client.ad_promotion(101), AsyncAdPromotion) + assert isinstance(client.autoload_profile(), AsyncAutoloadProfile) + assert isinstance(client.autoload_report(44), AsyncAutoloadReport) + assert isinstance(client.autoload_archive(44), AsyncAutoloadArchive) + await client.aclose() diff --git a/tests/domains/autoteka/test_autoteka_async.py b/tests/domains/autoteka/test_autoteka_async.py new file mode 100644 index 0000000..8351b33 --- /dev/null +++ b/tests/domains/autoteka/test_autoteka_async.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +import json + +import httpx +import pytest + +from avito.async_client import AsyncAvitoClient +from avito.autoteka import ( + AsyncAutotekaMonitoring, + AsyncAutotekaReport, + AsyncAutotekaScoring, + AsyncAutotekaValuation, + AsyncAutotekaVehicle, +) +from avito.config import AvitoSettings +from avito.core.async_transport import AsyncTransport +from avito.testing import AsyncFakeTransport + + +@pytest.mark.asyncio +async def test_async_autoteka_vehicle_flows() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + payload = json.loads(request.content.decode()) if request.content else None + if path == "/autoteka/v1/catalogs/resolve": + assert payload == {"fieldsValueIds": [{"id": 110000, "valueId": 1}]} + return httpx.Response( + 200, + json={ + "result": { + "fields": [ + { + "id": 110000, + "label": "Марка", + "dataType": "integer", + "values": [{"valueId": 1, "label": "Audi"}], + } + ] + } + }, + ) + if path == "/autoteka/v1/get-leads/": + return httpx.Response( + 200, + json={ + "pagination": {"lastId": 321}, + "result": [ + { + "id": 12, + "subscriptionId": 44, + "payload": {"vin": "VIN-1", "itemId": 901, "brand": "Audi"}, + } + ], + }, + ) + if path == "/autoteka/v1/previews": + return httpx.Response(200, json={"result": {"preview": {"previewId": 77}}}) + if path == "/autoteka/v1/request-preview-by-item-id": + return httpx.Response(200, json={"result": {"preview": {"previewId": 78}}}) + if path == "/autoteka/v1/request-preview-by-regnumber": + return httpx.Response(200, json={"result": {"preview": {"previewId": 79}}}) + if path == "/autoteka/v1/request-preview-by-external-item": + return httpx.Response(200, json={"result": {"preview": {"previewId": 80}}}) + if path == "/autoteka/v1/previews/77": + return httpx.Response( + 200, + json={ + "result": { + "preview": { + "previewId": 77, + "status": "success", + "vin": "VIN-1", + "regNumber": "A123AA77", + } + } + }, + ) + if path == "/autoteka/v1/specifications/by-plate-number": + return httpx.Response(200, json={"result": {"specification": {"specificationId": 501}}}) + if path == "/autoteka/v1/specifications/by-vehicle-id": + return httpx.Response(200, json={"result": {"specification": {"specificationId": 502}}}) + if path == "/autoteka/v1/specifications/specification/501": + return httpx.Response( + 200, + json={ + "result": { + "specification": { + "specificationId": 501, + "status": "success", + "vehicleId": "VIN-1", + } + } + }, + ) + if path == "/autoteka/v1/teasers": + return httpx.Response( + 200, + json={"result": {"teaser": {"teaserId": 601, "status": "processing"}}}, + ) + return httpx.Response( + 200, + json={ + "teaserId": 601, + "status": "success", + "data": {"brand": "Audi", "model": "A4", "year": 2018}, + }, + ) + + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(handler), + base_url="https://api.avito.ru", + ) + transport = AsyncTransport(AvitoSettings(), client=http_client) + vehicle = AsyncAutotekaVehicle(transport, vehicle_id="77") + + assert (await vehicle.resolve_catalog(brand_id=1)).items[0].values[0].label == "Audi" + assert (await vehicle.get_leads(subscription_id=44, limit=1)).last_id == 321 + assert (await vehicle.create_preview_by_vin(vin="VIN-1")).preview_id == "77" + assert (await vehicle.create_preview_by_item_id(item_id=901)).preview_id == "78" + assert (await vehicle.create_preview_by_reg_number(reg_number="A123AA77")).preview_id == "79" + assert ( + await vehicle.create_preview_by_external_item(item_id="ext-1", site="cars.example") + ).preview_id == "80" + assert (await vehicle.get_preview()).vehicle_id == "VIN-1" + assert ( + await vehicle.create_specification_by_plate_number(plate_number="A123AA77") + ).specification_id == "501" + assert ( + await vehicle.create_specification_by_vehicle_id(vehicle_id="VIN-1") + ).specification_id == "502" + assert (await vehicle.get_specification_by_id(specification_id="501")).status == "success" + assert (await vehicle.create_teaser(vehicle_id="VIN-1")).teaser_id == "601" + assert (await vehicle.get_teaser(teaser_id="601")).brand == "Audi" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_autoteka_report_monitoring_scoring_and_valuation_flows() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == "/autoteka/v1/packages/active_package": + return httpx.Response( + 200, + json={ + "result": { + "package": { + "createdTime": "2026-04-01", + "expireTime": "2026-05-01", + "reportsCnt": 100, + "reportsCntRemain": 77, + } + } + }, + ) + if path == "/autoteka/v1/reports": + return httpx.Response( + 200, + json={"result": {"report": {"reportId": 701, "status": "processing"}}}, + ) + if path == "/autoteka/v1/reports-by-vehicle-id": + return httpx.Response( + 200, + json={"result": {"report": {"reportId": 702, "status": "processing"}}}, + ) + if path == "/autoteka/v1/reports/list/": + return httpx.Response( + 200, + json={ + "result": [ + {"reportId": 701, "vin": "VIN-1", "createdAt": "2026-04-18 12:00:00"} + ] + }, + ) + if path == "/autoteka/v1/reports/701": + return httpx.Response( + 200, + json={ + "result": { + "report": { + "reportId": 701, + "status": "success", + "webLink": "https://autoteka/web/701", + "pdfLink": "https://autoteka/pdf/701", + "data": {"vin": "VIN-1"}, + } + } + }, + ) + if path == "/autoteka/v1/sync/create-by-regnumber": + return httpx.Response( + 200, + json={ + "result": { + "report": {"reportId": 703, "status": "success", "data": {"vin": "VIN-1"}} + } + }, + ) + if path == "/autoteka/v1/sync/create-by-vin": + return httpx.Response( + 200, + json={ + "result": { + "report": {"reportId": 704, "status": "success", "data": {"vin": "VIN-1"}} + } + }, + ) + if path == "/autoteka/v1/monitoring/bucket/add": + return httpx.Response( + 200, + json={ + "result": { + "isOk": True, + "invalidVehicles": [{"vehicleID": "bad-vin", "description": "invalid"}], + } + }, + ) + if path == "/autoteka/v1/monitoring/bucket/delete": + return httpx.Response(200, json={"result": {"isOk": True}}) + if path == "/autoteka/v1/monitoring/bucket/remove": + return httpx.Response(200, json={"result": {"isOk": True, "invalidVehicles": []}}) + if path == "/autoteka/v1/monitoring/get-reg-actions/": + return httpx.Response( + 200, + json={ + "data": [ + { + "vin": "VIN-1", + "brand": "Audi", + "model": "A4", + "year": 2018, + "operationCode": 11, + "operationDateFrom": "2026-04-01T00:00:00+03:00", + } + ], + "pagination": {"hasNext": True, "nextCursor": "cursor-2"}, + }, + ) + if path == "/autoteka/v1/scoring/by-vehicle-id": + return httpx.Response(200, json={"result": {"scoring": {"scoringId": 801}}}) + if path == "/autoteka/v1/scoring/801": + return httpx.Response( + 200, + json={"result": {"risksAssessment": {"scoringId": 801, "isCompleted": True}}}, + ) + return httpx.Response( + 200, + json={ + "result": { + "status": "success", + "vehicleId": "VIN-1", + "brand": "Audi", + "model": "A4", + "year": 2018, + "valuation": {"avgPriceWithCondition": 2100000}, + } + }, + ) + + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(handler), + base_url="https://api.avito.ru", + ) + transport = AsyncTransport(AvitoSettings(), client=http_client) + report = AsyncAutotekaReport(transport, report_id="701") + monitoring = AsyncAutotekaMonitoring(transport) + scoring = AsyncAutotekaScoring(transport, scoring_id="801") + valuation = AsyncAutotekaValuation(transport) + + assert (await report.get_active_package()).reports_remaining == 77 + assert (await report.create_report(preview_id=77)).report_id == "701" + assert (await report.create_report_by_vehicle_id(vehicle_id="VIN-1")).report_id == "702" + assert (await report.list_reports()).items[0].vehicle_id == "VIN-1" + assert (await report.get_report()).web_link == "https://autoteka/web/701" + assert ( + await report.create_sync_report_by_reg_number(reg_number="A123AA77") + ).status == "success" + assert (await report.create_sync_report_by_vin(vin="VIN-1")).report_id == "704" + assert ( + await monitoring.create_monitoring_bucket_add(vehicles=["VIN-1", "bad-vin"]) + ).invalid_vehicles[0].vehicle_id == "bad-vin" + assert (await monitoring.delete_bucket()).success is True + assert (await monitoring.remove_bucket(vehicles=["VIN-1"])).success is True + assert (await monitoring.get_monitoring_reg_actions(limit=10)).items[0].operation_code == 11 + assert (await scoring.create_scoring_by_vehicle_id(vehicle_id="VIN-1")).scoring_id == "801" + assert (await scoring.get_scoring_by_id()).is_completed is True + assert ( + await valuation.get_valuation_by_specification(specification_id=501, mileage=30000) + ).avg_price_with_condition == 2100000 + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_autoteka_factories_return_async_domains() -> None: + client = AsyncFakeTransport().as_client(authenticated=True) + + assert isinstance(client.autoteka_vehicle("VIN-1"), AsyncAutotekaVehicle) + assert isinstance(client.autoteka_report("701"), AsyncAutotekaReport) + assert isinstance(client.autoteka_monitoring(), AsyncAutotekaMonitoring) + assert isinstance(client.autoteka_scoring("801"), AsyncAutotekaScoring) + assert isinstance(client.autoteka_valuation(), AsyncAutotekaValuation) + await client.aclose() + + +def test_async_client_autoteka_factories_require_entered_client() -> None: + client = AsyncAvitoClient( + AvitoSettings(), + client_id="id", + client_secret="secret", + ) + + with pytest.raises(RuntimeError, match="async with"): + client.autoteka_vehicle() diff --git a/tests/domains/cpa/test_cpa_async.py b/tests/domains/cpa/test_cpa_async.py new file mode 100644 index 0000000..4ec0fd6 --- /dev/null +++ b/tests/domains/cpa/test_cpa_async.py @@ -0,0 +1,404 @@ +from __future__ import annotations + +import logging + +import httpx +import pytest + +from avito.async_client import AsyncAvitoClient +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core import ValidationError +from avito.core.retries import RetryPolicy +from avito.cpa import ( + AsyncCallTrackingCall, + AsyncCpaArchive, + AsyncCpaCall, + AsyncCpaChat, + AsyncCpaLead, +) +from avito.cpa.models import CpaCallStatusId +from avito.testing import AsyncFakeTransport +from avito.testing.fake_transport import RecordedRequest + + +@pytest.mark.asyncio +async def test_async_cpa_chat_and_phone_flows() -> None: + def chats_v1(request: RecordedRequest) -> httpx.Response: + assert request.json_body == { + "dateTimeFrom": "2026-04-18T00:00:00+03:00", + "limit": 10, + "offset": 0, + } + return httpx.Response( + 200, + json={ + "chats": [ + { + "chat": {"id": "chat-v1", "actionId": "legacy-1"}, + "buyer": {"userId": 502, "name": "Петр"}, + "item": {"id": 9002, "title": "Самокат"}, + "isArbitrageAvailable": False, + } + ] + }, + ) + + def chats_v2(request: RecordedRequest) -> httpx.Response: + assert request.json_body == { + "dateTimeFrom": "2026-04-18T00:00:00+03:00", + "limit": 10, + "offset": 0, + } + return httpx.Response( + 200, + json={ + "chats": [ + { + "chat": {"id": "chat-v2", "actionId": "act-2"}, + "buyer": {"userId": 503, "name": "Мария"}, + "item": {"id": 9003, "title": "Ноутбук"}, + "isArbitrageAvailable": True, + } + ] + }, + ) + + def phones(request: RecordedRequest) -> httpx.Response: + assert request.json_body == { + "dateTimeFrom": "2026-04-18T00:00:00+03:00", + "limit": 10, + "offset": 0, + } + return httpx.Response( + 200, + json={ + "total": 2, + "results": [ + { + "id": 101, + "date": "2026-04-18T12:00:00+03:00", + "phone_number": "+79990000001", + }, + { + "id": 102, + "date": "2026-04-18T12:05:00+03:00", + "phone_number": "+79990000002", + }, + ], + }, + ) + + fake = ( + AsyncFakeTransport() + .add_json( + "GET", + "/cpa/v1/chatByActionId/act-1", + { + "chat": { + "chat": {"id": "chat-1", "actionId": "act-1"}, + "buyer": {"userId": 501, "name": "Иван"}, + "item": {"id": 9001, "title": "Велосипед"}, + "isArbitrageAvailable": True, + } + }, + ) + .add("POST", "/cpa/v1/chatsByTime", chats_v1) + .add("POST", "/cpa/v2/chatsByTime", chats_v2) + .add("POST", "/cpa/v1/phonesInfoFromChats", phones) + ) + transport = fake.build() + chat = AsyncCpaChat(transport, action_id="act-1") + + assert (await chat.get()).item_title == "Велосипед" + with pytest.deprecated_call(match="cpa_chat\\(\\)\\.list\\(version=2\\)"): + classic_chats = await chat.list( + created_at_from="2026-04-18T00:00:00+03:00", + limit=10, + offset=0, + version=1, + ) + assert classic_chats.items[0].buyer_name == "Петр" + assert ( + await chat.list( + created_at_from="2026-04-18T00:00:00+03:00", + limit=10, + offset=0, + ) + ).items[0].is_arbitrage_available is True + assert ( + await chat.get_phones_info_from_chats( + date_time_from="2026-04-18T00:00:00+03:00", + limit=10, + offset=0, + ) + ).items[1].phone_number == "+79990000002" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_cpa_calls_archive_and_balance_flows() -> None: + audio_bytes = b"ID3 fake audio" + fake = ( + AsyncFakeTransport() + .add_json( + "POST", + "/cpa/v2/callsByTime", + { + "calls": [ + { + "id": 2001, + "itemId": 3001, + "buyerPhone": "+79990000010", + "sellerPhone": "+79990000011", + "virtualPhone": "+79990000012", + "statusId": 2, + "price": 171600, + "duration": 119, + "waitingDuration": 0.5, + "createTime": "2026-04-18T11:00:00+03:00", + "recordUrl": "https://example.com/record-2001.mp3", + } + ] + }, + ) + .add_json("POST", "/cpa/v1/createComplaint", {"success": True}) + .add_json("POST", "/cpa/v1/createComplaintByActionId", {"success": True}) + .add_json("POST", "/cpa/v3/balanceInfo", {"balance": -5000}) + .add_json("POST", "/cpa/v2/balanceInfo", {"balance": -5000, "advance": 1000, "debt": 0}) + .add_json( + "POST", + "/cpa/v2/callById", + { + "calls": { + "id": 2001, + "itemId": 3001, + "buyerPhone": "+79990000010", + "sellerPhone": "+79990000011", + "virtualPhone": "+79990000012", + "statusId": 2, + "price": 171600, + "duration": 119, + "waitingDuration": 0.5, + "createTime": "2026-04-18T11:00:00+03:00", + } + }, + ) + .add( + "GET", + "/cpa/v1/call/2001", + httpx.Response( + 200, + content=audio_bytes, + headers={ + "content-type": "audio/mpeg", + "content-disposition": 'attachment; filename="call-2001.mp3"', + }, + ), + ) + ) + transport = fake.build() + cpa_call = AsyncCpaCall(transport) + cpa_lead = AsyncCpaLead(transport) + archive = AsyncCpaArchive(transport, call_id="2001") + + assert ( + await cpa_call.list(date_time_from="2026-04-18T00:00:00+03:00", limit=100) + ).items[0].record_url == "https://example.com/record-2001.mp3" + assert (await cpa_call.create_complaint(call_id=2001, reason="spam")).success is True + assert ( + await cpa_lead.create_complaint_by_action_id(action_id=101, reason="duplicate") + ).success is True + assert (await cpa_lead.get_balance_info()).balance == -5000 + with pytest.deprecated_call(match="cpa_lead\\(\\)\\.get_balance_info"): + archived_balance = await archive.get_balance_info() + with pytest.deprecated_call(match="call_tracking_call\\(\\)\\.get"): + archived_call = await archive.get_call_by_id(call_id=2001) + with pytest.deprecated_call(match="call_tracking_call\\(\\)\\.download"): + archived_audio = await archive.get_call() + assert archived_balance.advance == 1000 + assert archived_call.call_id == "2001" + assert archived_audio.binary.content == audio_bytes + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_cpa_complaint_idempotency_key_is_stable_across_retry() -> None: + calls = {"count": 0} + seen_keys: list[str | None] = [] + + def create_complaint(request: RecordedRequest) -> httpx.Response: + calls["count"] += 1 + seen_keys.append(request.headers.get("idempotency-key")) + if calls["count"] == 1: + raise httpx.ConnectError("offline") + assert request.path == "/cpa/v1/createComplaint" + return httpx.Response(200, json={"success": True}) + + fake = AsyncFakeTransport().add("POST", "/cpa/v1/createComplaint", create_complaint) + transport = fake.build(retry_policy=RetryPolicy(max_attempts=2)) + cpa_call = AsyncCpaCall(transport) + + result = await cpa_call.create_complaint( + call_id=2001, + reason="spam", + idempotency_key="idem-cpa-complaint", + ) + + assert result.success is True + assert calls["count"] == 2 + assert seen_keys == ["idem-cpa-complaint", "idem-cpa-complaint"] + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_cpa_call_unknown_status_id_maps_to_unknown_and_warns_once( + caplog: pytest.LogCaptureFixture, +) -> None: + fake = AsyncFakeTransport().add_json( + "POST", + "/cpa/v2/callsByTime", + {"calls": [{"id": 2001, "itemId": 3001, "statusId": 998}]}, + ) + transport = fake.build() + caplog.set_level(logging.WARNING, logger="avito.core.enums") + cpa_call = AsyncCpaCall(transport) + + first = ( + await cpa_call.list( + date_time_from="2026-04-18T00:00:00+03:00", + limit=100, + ) + ).items[0] + second = ( + await cpa_call.list( + date_time_from="2026-04-18T00:00:00+03:00", + limit=100, + ) + ).items[0] + + assert first.status_id is CpaCallStatusId.UNKNOWN + assert second.status_id is CpaCallStatusId.UNKNOWN + records = [ + record + for record in caplog.records + if getattr(record, "enum", None) == "cpa.call_status_id" + and getattr(record, "value", None) == 998 + ] + assert len(records) == 1 + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_calltracking_flows() -> None: + audio_bytes = b"RIFF fake wave" + fake = ( + AsyncFakeTransport() + .add_json( + "POST", + "/calltracking/v1/getCallById/", + { + "call": { + "callId": 7001, + "itemId": 9901, + "buyerPhone": "+79990000100", + "sellerPhone": "+79990000101", + "virtualPhone": "+79990000102", + "callTime": "2026-04-18T09:00:00Z", + "talkDuration": 67, + "waitingDuration": 1.25, + }, + "error": {"code": 0, "message": ""}, + }, + ) + .add_json( + "POST", + "/calltracking/v1/getCalls/", + { + "calls": [ + { + "callId": 7001, + "itemId": 9901, + "buyerPhone": "+79990000100", + "sellerPhone": "+79990000101", + "virtualPhone": "+79990000102", + "callTime": "2026-04-18T09:00:00Z", + "talkDuration": 67, + "waitingDuration": 1.25, + } + ], + "error": {"code": 0, "message": ""}, + }, + ) + .add( + "GET", + "/calltracking/v1/getRecordByCallId/", + httpx.Response( + 200, + content=audio_bytes, + headers={ + "content-type": "audio/wav", + "content-disposition": 'attachment; filename="record-7001.wav"', + }, + ), + ) + ) + transport = fake.build() + call = AsyncCallTrackingCall(transport, call_id="7001") + + assert (await call.get()).call.call_id == "7001" + assert ( + await call.list( + date_time_from="2026-04-01T00:00:00Z", + date_time_to="2026-04-18T23:59:59Z", + limit=100, + offset=0, + ) + ).items[0].buyer_phone == "+79990000100" + assert (await call.download()).binary.content == audio_bytes + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_cpa_rejects_invalid_datetime_before_transport() -> None: + transport = AsyncFakeTransport().build() + chat = AsyncCpaChat(transport) + call = AsyncCpaCall(transport) + tracking = AsyncCallTrackingCall(transport) + + with pytest.raises(ValidationError, match="created_at_from"): + await chat.list(created_at_from="18.04.2026", limit=10, offset=0) + with pytest.raises(ValidationError, match="date_time_from"): + await call.list(date_time_from="", limit=100) + with pytest.raises(ValidationError, match="date_time_to"): + await tracking.list(date_time_from="2026-04-01T00:00:00Z", date_time_to="not-a-date") + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_cpa_factories_return_async_domains() -> None: + client = AsyncFakeTransport().as_client() + + assert isinstance(client.cpa_lead(), AsyncCpaLead) + assert isinstance(client.cpa_chat(chat_id="act-1"), AsyncCpaChat) + assert isinstance(client.cpa_call(), AsyncCpaCall) + assert isinstance(client.cpa_archive(call_id=2001), AsyncCpaArchive) + assert isinstance(client.call_tracking_call(call_id=7001), AsyncCallTrackingCall) + await client.aclose() + + +def test_async_client_cpa_factories_require_entered_client() -> None: + client = AsyncAvitoClient( + AvitoSettings(auth=AuthSettings(client_id="id", client_secret="secret")) + ) + + with pytest.raises(RuntimeError): + client.cpa_lead() + with pytest.raises(RuntimeError): + client.cpa_chat() + with pytest.raises(RuntimeError): + client.cpa_call() + with pytest.raises(RuntimeError): + client.cpa_archive() + with pytest.raises(RuntimeError): + client.call_tracking_call() diff --git a/tests/domains/jobs/test_jobs_async.py b/tests/domains/jobs/test_jobs_async.py new file mode 100644 index 0000000..0bc3671 --- /dev/null +++ b/tests/domains/jobs/test_jobs_async.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +import httpx +import pytest + +from avito.async_client import AsyncAvitoClient +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core import ValidationError +from avito.jobs import ( + AsyncApplication, + AsyncJobDictionary, + AsyncJobWebhook, + AsyncResume, + AsyncVacancy, +) +from avito.jobs.models import ( + ApplicationViewedItem, + VacancyBillingType, + VacancyEmployment, + VacancyExperience, + VacancySchedule, +) +from avito.testing import AsyncFakeTransport +from avito.testing.fake_transport import RecordedRequest + + +@pytest.mark.asyncio +async def test_async_application_webhook_and_resume_flows() -> None: + fake = ( + AsyncFakeTransport() + .add_json( + "GET", + "/job/v1/applications/get_ids", + { + "items": [{"id": "app-1", "updatedAt": "2026-04-18T10:00:00+03:00"}], + "cursor": "app-1", + }, + ) + .add_json( + "POST", + "/job/v1/applications/get_by_ids", + { + "applies": [ + { + "id": "app-1", + "vacancy_id": 101, + "state": "new", + "is_viewed": False, + "applicant": {"name": "Иван"}, + } + ] + }, + ) + .add_json( + "GET", + "/job/v1/applications/get_states", + {"states": [{"slug": "new", "description": "Новый отклик"}]}, + ) + .add_json("POST", "/job/v1/applications/set_is_viewed", {"ok": True, "status": "viewed"}) + .add_json( + "POST", + "/job/v1/applications/apply_actions", + {"ok": True, "status": "invited"}, + ) + .add_json( + "GET", + "/job/v1/applications/webhook", + {"url": "https://example.com/job", "is_active": True, "version": "v1"}, + ) + .add_json( + "PUT", + "/job/v1/applications/webhook", + {"url": "https://example.com/job", "is_active": True, "version": "v1"}, + ) + .add_json("DELETE", "/job/v1/applications/webhook", {"ok": True}) + .add_json( + "GET", + "/job/v1/applications/webhooks", + [{"url": "https://example.com/job", "is_active": True, "version": "v1"}], + ) + .add_json( + "GET", + "/job/v1/resumes/", + { + "meta": {"cursor": "2", "total": 1}, + "resumes": [ + { + "id": "res-1", + "title": "Оператор call-центра", + "name": "Петр", + "location": "Москва", + "salary": 90000, + } + ], + }, + ) + .add_json( + "GET", + "/job/v1/resumes/res-1/contacts/", + {"name": "Петр", "phone": "+79990000000", "email": "petr@example.com"}, + ) + .add_json( + "GET", + "/job/v2/resumes/res-1", + { + "id": "res-1", + "title": "Оператор call-центра", + "fullName": "Петр Петров", + "address_details": {"location": "Москва"}, + "salary": {"from": 90000}, + }, + ) + ) + transport = fake.build() + application = AsyncApplication(transport) + webhook = AsyncJobWebhook(transport) + resume = AsyncResume(transport, resume_id="res-1") + + assert (await application.get_ids(updated_at_from="2026-04-18")).items[0].id == "app-1" + assert (await application.get_by_ids(ids=["app-1"])).items[0].applicant_name == "Иван" + assert (await application.get_states()).items[0].slug == "new" + assert ( + await application.update(applies=[ApplicationViewedItem(id="app-1", is_viewed=True)]) + ).status == "viewed" + assert (await application.apply(ids=["app-1"], action="invited")).status == "invited" + assert (await webhook.get()).url == "https://example.com/job" + assert ( + await webhook.update( + url="https://example.com/job", + secret="cb1e150b-c5bf-4c3e-acd1-20ec88bdb3a1", + idempotency_key="idem-webhook", + ) + ).is_active is True + assert (await webhook.delete(url="https://example.com/job")).success is True + assert (await webhook.list()).items[0].version == "v1" + assert (await resume.list(query="оператор")).items[0].candidate_name == "Петр" + assert (await resume.get_contacts()).phone == "+79990000000" + assert (await resume.get()).location == "Москва" + assert ( + fake.last(method="PUT", path="/job/v1/applications/webhook").headers["idempotency-key"] + == "idem-webhook" + ) + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_vacancy_and_dictionary_flows() -> None: + def update_auto_renewal(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"auto_renewal": True} + assert request.headers["idempotency-key"] == "idem-auto-renewal" + return httpx.Response(200, json={"ok": True, "status": "auto-renewal-updated"}) + + fake = ( + AsyncFakeTransport() + .add_json("POST", "/job/v1/vacancies", {"id": 101, "status": "created"}, status_code=201) + .add_json("PUT", "/job/v1/vacancies/101", {"ok": True, "status": "updated"}) + .add_json( + "PUT", + "/job/v1/vacancies/archived/101", + {"ok": True, "status": "archived"}, + ) + .add_json( + "POST", + "/job/v1/vacancies/101/prolongate", + {"ok": True, "status": "prolongated"}, + ) + .add_json( + "GET", + "/job/v2/vacancies", + { + "vacancies": [ + {"id": 101, "uuid": "vac-uuid-1", "title": "Продавец", "status": "active"} + ], + "total": 1, + }, + ) + .add_json( + "POST", + "/job/v2/vacancies", + {"vacancy_uuid": "vac-uuid-1", "status": "created"}, + status_code=202, + ) + .add_json( + "POST", + "/job/v2/vacancies/batch", + { + "vacancies": [ + {"id": 101, "uuid": "vac-uuid-1", "title": "Продавец", "status": "active"} + ] + }, + ) + .add_json( + "POST", + "/job/v2/vacancies/statuses", + {"items": [{"id": 101, "uuid": "vac-uuid-1", "status": "active"}]}, + ) + .add_json( + "POST", + "/job/v2/vacancies/update/vac-uuid-1", + {"vacancy_uuid": "vac-uuid-1", "status": "updated"}, + status_code=202, + ) + .add_json( + "GET", + "/job/v2/vacancies/101", + { + "id": 101, + "uuid": "vac-uuid-1", + "title": "Продавец", + "status": "active", + "url": "https://avito.ru/vacancy/101", + }, + ) + .add("PUT", "/job/v2/vacancies/vac-uuid-1/auto_renewal", update_auto_renewal) + .add_json("GET", "/job/v2/vacancy/dict", [{"id": "profession", "description": "Профессия"}]) + .add_json( + "GET", + "/job/v2/vacancy/dict/profession", + [{"id": 10106, "name": "IT, интернет, телеком", "deprecated": True}], + ) + ) + transport = fake.build() + vacancy = AsyncVacancy(transport, vacancy_id="101") + dictionary = AsyncJobDictionary(transport, dictionary_id="profession") + + assert ( + await vacancy.create( + title="Продавец", + billing_type=VacancyBillingType.PACKAGE, + description="Описание вакансии", + business_area=7, + employment=VacancyEmployment.FULL, + schedule=VacancySchedule.FIXED, + experience=VacancyExperience.NO_MATTER, + version=1, + ) + ).id == "101" + assert ( + await vacancy.update(title="Старший продавец", billing_type="package", version=1) + ).status == "updated" + assert (await vacancy.delete(employee_id=7)).status == "archived" + assert (await vacancy.prolongate(billing_type="package")).status == "prolongated" + assert (await vacancy.list()).items[0].uuid == "vac-uuid-1" + assert ( + await vacancy.create(title="Вакансия v2", billing_type=VacancyBillingType.PACKAGE) + ).id == "vac-uuid-1" + assert (await vacancy.get_by_ids(ids=[101])).items[0].title == "Продавец" + assert (await vacancy.get_statuses(ids=["vac-uuid-1"])).items[0].status == "active" + assert ( + await vacancy.update( + title="Вакансия v2 updated", + billing_type="package", + version=2, + vacancy_uuid="vac-uuid-1", + ) + ).status == "updated" + assert (await vacancy.get()).url == "https://avito.ru/vacancy/101" + assert ( + await vacancy.update_auto_renewal( + auto_renewal=True, + vacancy_uuid="vac-uuid-1", + idempotency_key="idem-auto-renewal", + ) + ).status == "auto-renewal-updated" + assert (await dictionary.list()).items[0].id == "profession" + assert (await dictionary.get()).items[0].deprecated is True + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_application_rejects_invalid_updated_at_before_transport() -> None: + fake = AsyncFakeTransport() + transport = fake.build() + application = AsyncApplication(transport) + + with pytest.raises(ValidationError, match="updated_at_from"): + await application.get_ids(updated_at_from="18-04-2026") + + assert fake.count() == 0 + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_vacancy_rejects_unknown_closed_values_before_transport() -> None: + fake = AsyncFakeTransport() + transport = fake.build() + vacancy = AsyncVacancy(transport) + + with pytest.raises(ValidationError, match="billing_type"): + await vacancy.create(title="Вакансия", billing_type="unknown") + with pytest.raises(ValidationError, match="employment"): + await vacancy.create( + title="Вакансия", + billing_type=VacancyBillingType.PACKAGE, + description="Описание", + business_area=7, + employment="unknown", + schedule=VacancySchedule.FIXED, + experience=VacancyExperience.NO_MATTER, + version=1, + ) + + assert fake.count() == 0 + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_jobs_factories_return_async_domains() -> None: + client = AsyncFakeTransport().as_client() + + assert isinstance(client.vacancy("101"), AsyncVacancy) + assert isinstance(client.application(), AsyncApplication) + assert isinstance(client.resume("res-1"), AsyncResume) + assert isinstance(client.job_webhook(), AsyncJobWebhook) + assert isinstance(client.job_dictionary("profession"), AsyncJobDictionary) + await client.aclose() + + +def test_async_client_jobs_factories_require_entered_client() -> None: + client = AsyncAvitoClient( + AvitoSettings(auth=AuthSettings(client_id="id", client_secret="secret")) + ) + + with pytest.raises(RuntimeError, match="async with"): + client.vacancy("101") diff --git a/tests/domains/messenger/test_messenger_async.py b/tests/domains/messenger/test_messenger_async.py new file mode 100644 index 0000000..8e0a3a9 --- /dev/null +++ b/tests/domains/messenger/test_messenger_async.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import httpx +import pytest + +from avito.async_client import AsyncAvitoClient +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core import ValidationError +from avito.messenger import ( + AsyncChat, + AsyncChatMedia, + AsyncChatMessage, + AsyncChatWebhook, + AsyncSpecialOfferCampaign, +) +from avito.messenger.models import UploadImageFile +from avito.testing import AsyncFakeTransport +from avito.testing.fake_transport import RecordedRequest + + +@pytest.mark.asyncio +async def test_async_messenger_chat_message_and_media_flows() -> None: + def blacklist(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"users": [{"user_id": 42}]} + assert request.headers["idempotency-key"] == "idem-blacklist" + return httpx.Response(200, json={"success": True}) + + def send_message(request: RecordedRequest) -> httpx.Response: + assert request.json_body == { + "message": {"text": "Здравствуйте"}, + "type": "text", + } + return httpx.Response(200, json={"success": True, "message_id": "msg-1", "status": "sent"}) + + def send_image(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"image_id": "img-1", "caption": "Фото"} + return httpx.Response( + 200, json={"success": True, "message_id": "msg-img-1", "status": "sent"} + ) + + fake = ( + AsyncFakeTransport() + .add_json( + "GET", + "/messenger/v2/accounts/7/chats", + {"chats": [{"id": "chat-1", "user_id": 7, "title": "Покупатель"}]}, + ) + .add_json( + "GET", + "/messenger/v2/accounts/7/chats/chat-1", + {"id": "chat-1", "user_id": 7, "title": "Покупатель"}, + ) + .add("POST", "/messenger/v2/accounts/7/blacklist", blacklist) + .add_json("POST", "/messenger/v1/accounts/7/chats/chat-1/read", {"success": True}) + .add_json( + "GET", + "/messenger/v3/accounts/7/chats/chat-1/messages/", + {"messages": [{"id": "msg-1", "chat_id": "chat-1", "text": "Здравствуйте"}]}, + ) + .add("POST", "/messenger/v1/accounts/7/chats/chat-1/messages", send_message) + .add("POST", "/messenger/v1/accounts/7/chats/chat-1/messages/image", send_image) + .add_json( + "POST", + "/messenger/v1/accounts/7/chats/chat-1/messages/msg-1", + {"success": True, "status": "confirmed"}, + ) + .add_json( + "GET", + "/messenger/v1/accounts/7/getVoiceFiles", + {"voice_files": [{"id": "voice-1", "url": "https://cdn/voice-1.ogg", "duration": 3}]}, + ) + .add_json( + "POST", + "/messenger/v1/accounts/7/uploadImages", + {"images": [{"image_id": "img-1", "url": "https://cdn/img-1.jpg"}]}, + ) + ) + transport = fake.build() + chat = AsyncChat(transport, chat_id="chat-1", user_id=7) + message = AsyncChatMessage(transport, chat_id="chat-1", message_id="msg-1", user_id=7) + media = AsyncChatMedia(transport, user_id=7) + + chats = await chat.list() + info = await chat.get() + blocked = await chat.blacklist(blacklisted_user_id=42, idempotency_key="idem-blacklist") + read = await chat.mark_read() + messages = await message.list() + sent = await message.send_message(message="Здравствуйте") + image_sent = await message.send_image(image_id="img-1", caption="Фото") + deleted = await message.delete() + voices = await media.get_voice_files(voice_ids=["voice-1"]) + uploaded = await media.upload_images( + files=[ + UploadImageFile( + field_name="image", + filename="photo.jpg", + content=b"binary", + content_type="image/jpeg", + ) + ] + ) + + assert chats.items[0].chat_id == "chat-1" + assert info.title == "Покупатель" + assert blocked.success is True + assert read.success is True + assert messages.items[0].message_id == "msg-1" + assert sent.message_id == "msg-1" + assert image_sent.message_id == "msg-img-1" + assert deleted.success is True + assert voices.items[0].id == "voice-1" + assert uploaded.items[0].image_id == "img-1" + assert fake.last(method="GET", path="/messenger/v1/accounts/7/getVoiceFiles").params == { + "voice_ids": "voice-1" + } + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_messenger_webhook_and_special_offer_flows() -> None: + def subscribe(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"url": "https://example.com/hook", "secret": "top-secret"} + return httpx.Response(200, json={"success": True, "status": "subscribed"}) + + def unsubscribe(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"url": "https://example.com/hook"} + return httpx.Response(200, json={"success": True, "status": "confirmed"}) + + def available(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"itemIds": [1, 2]} + return httpx.Response( + 200, + json={"items": [{"itemId": 1, "title": "Объявление", "available": True}]}, + ) + + def create_multi(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"itemIds": [1]} + return httpx.Response(200, json={"campaign_id": "camp-1", "status": "draft"}) + + def confirm_multi(request: RecordedRequest) -> httpx.Response: + assert request.json_body == { + "dispatches": [ + { + "dispatchId": 1, + "recipientsCount": 20, + "offerSlug": "discount", + "discountValue": 10, + } + ], + "expiresAt": 1767225600, + } + return httpx.Response(200, json={"success": True, "status": "confirmed"}) + + def stats(request: RecordedRequest) -> httpx.Response: + assert request.json_body == { + "dateTimeFrom": "2026-05-01T00:00:00+03:00", + "dateTimeTo": "2026-05-02T00:00:00+03:00", + } + return httpx.Response( + 200, + json={ + "campaign_id": "camp-1", + "sent_count": 20, + "delivered_count": 18, + "read_count": 10, + }, + ) + + fake = ( + AsyncFakeTransport() + .add_json( + "POST", + "/messenger/v1/subscriptions", + { + "subscriptions": [ + {"url": "https://example.com/hook", "version": "v3", "status": "active"} + ] + }, + ) + .add("POST", "/messenger/v3/webhook", subscribe) + .add("POST", "/messenger/v1/webhook/unsubscribe", unsubscribe) + .add("POST", "/special-offers/v1/available", available) + .add("POST", "/special-offers/v1/multiCreate", create_multi) + .add("POST", "/special-offers/v1/multiConfirm", confirm_multi) + .add("POST", "/special-offers/v1/stats", stats) + .add_json( + "POST", "/special-offers/v1/tariffInfo", {"price": 5.5, "currency": "RUB", "limit": 100} + ) + ) + transport = fake.build() + webhook = AsyncChatWebhook(transport) + campaign = AsyncSpecialOfferCampaign(transport, campaign_id="camp-1") + + subscriptions = await webhook.list() + subscribed = await webhook.subscribe(url="https://example.com/hook", secret="top-secret") + unsubscribed = await webhook.unsubscribe(url="https://example.com/hook") + available_result = await campaign.get_available(item_ids=[1, 2]) + created = await campaign.create_multi(item_ids=[1]) + confirmed = await campaign.confirm_multi( + dispatch_id=1, + recipients_count=20, + offer_slug="discount", + discount_value=10, + expires_at=1767225600, + ) + stats_result = await campaign.get_stats( + date_time_from="2026-05-01T00:00:00+03:00", + date_time_to="2026-05-02T00:00:00+03:00", + ) + tariff = await campaign.get_tariff_info() + + assert subscriptions.items[0].status == "active" + assert subscribed.status == "subscribed" + assert unsubscribed.status == "confirmed" + assert available_result.items[0].item_id == 1 + assert created.status == "draft" + assert confirmed.status == "confirmed" + assert stats_result.delivered_count == 18 + assert tariff.daily_limit == 100 + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_messenger_factories_return_async_domains() -> None: + client = AsyncFakeTransport().as_client() + + assert isinstance(client.chat("chat-1", user_id=7), AsyncChat) + assert isinstance(client.chat_message("msg-1", chat_id="chat-1", user_id=7), AsyncChatMessage) + assert isinstance(client.chat_webhook(), AsyncChatWebhook) + assert isinstance(client.chat_media(user_id=7), AsyncChatMedia) + assert isinstance(client.special_offer_campaign("camp-1"), AsyncSpecialOfferCampaign) + await client.aclose() + + +def test_async_client_messenger_factories_require_entered_client() -> None: + client = AsyncAvitoClient( + AvitoSettings(auth=AuthSettings(client_id="id", client_secret="secret")) + ) + + with pytest.raises(RuntimeError): + client.chat() + with pytest.raises(RuntimeError): + client.chat_message() + with pytest.raises(RuntimeError): + client.chat_webhook() + with pytest.raises(RuntimeError): + client.chat_media() + with pytest.raises(RuntimeError): + client.special_offer_campaign() + + +@pytest.mark.asyncio +async def test_async_special_offer_stats_reject_invalid_datetime_before_transport() -> None: + fake = AsyncFakeTransport() + transport = fake.build() + campaign = AsyncSpecialOfferCampaign(transport, campaign_id="camp-1") + + with pytest.raises(ValidationError, match="date_time_from"): + await campaign.get_stats(date_time_from="not-a-date", date_time_to="2026-05-02T00:00:00Z") + + assert fake.count() == 0 + await transport.aclose() diff --git a/tests/domains/orders/test_orders_async.py b/tests/domains/orders/test_orders_async.py new file mode 100644 index 0000000..e41d75f --- /dev/null +++ b/tests/domains/orders/test_orders_async.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import httpx +import pytest + +from avito.core import ValidationError +from avito.orders import ( + AsyncDeliveryOrder, + AsyncDeliveryTask, + AsyncOrder, + AsyncOrderLabel, + AsyncSandboxDelivery, + AsyncStock, + OrderTransition, +) +from avito.orders.models import StockUpdateEntry +from avito.testing import AsyncFakeTransport +from avito.testing.fake_transport import RecordedRequest + + +@pytest.mark.asyncio +async def test_async_order_management_flows() -> None: + def update_markings(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"markings": [{"orderId": "ord-1", "markings": ["abc"]}]} + assert request.headers["idempotency-key"] == "markings-key" + return httpx.Response( + 200, + json={"result": {"success": True, "orderId": "ord-1", "status": "marked"}}, + ) + + fake = ( + AsyncFakeTransport() + .add_json( + "GET", + "/order-management/1/orders", + { + "orders": [ + {"id": "ord-1", "status": "new", "buyerInfo": {"fullName": "Иван"}} + ], + "total": 1, + }, + ) + .add("POST", "/order-management/1/markings", update_markings) + .add_json( + "POST", + "/order-management/1/order/applyTransition", + {"result": {"success": True, "orderId": "ord-1", "status": "confirmed"}}, + ) + .add_json( + "POST", + "/order-management/1/order/checkConfirmationCode", + {"result": {"success": True, "orderId": "ord-1", "status": "code-valid"}}, + ) + .add_json( + "GET", + "/order-management/1/order/getCourierDeliveryRange", + { + "result": { + "address": "Москва", + "timeIntervals": [ + { + "id": "int-1", + "date": "2026-04-18", + "startAt": "10:00", + "endAt": "12:00", + } + ], + } + }, + ) + .add_json( + "POST", + "/order-management/1/order/setCourierDeliveryRange", + {"result": {"success": True, "status": "range-set"}}, + ) + .add_json( + "POST", + "/order-management/1/order/setTrackingNumber", + {"result": {"success": True, "status": "tracking-set"}}, + ) + .add_json( + "POST", + "/order-management/1/order/acceptReturnOrder", + {"result": {"success": True, "status": "return-accepted"}}, + ) + ) + transport = fake.build() + order = AsyncOrder(transport) + + assert (await order.list()).items[0].buyer_name == "Иван" + assert ( + await order.update_markings( + order_id="ord-1", + codes=["abc"], + idempotency_key="markings-key", + ) + ).status == "marked" + assert (await order.apply(order_id="ord-1", transition=OrderTransition.CONFIRM)).status == "confirmed" + assert (await order.check_confirmation_code(order_id="ord-1", code="1234")).status == "code-valid" + assert (await order.get_courier_delivery_range()).items[0].interval_id == "int-1" + assert (await order.set_courier_delivery_range(order_id="ord-1", interval_id="int-1")).status == "range-set" + assert (await order.update_tracking_number(order_id="ord-1", tracking_number="TRK-1")).status == "tracking-set" + assert (await order.accept_return_order(order_id="ord-1", postal_office_id="ops-1")).status == "return-accepted" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_labels_delivery_and_stock_flows() -> None: + pdf_bytes = b"%PDF-1.4 fake" + + def create_announcement(request: RecordedRequest) -> httpx.Response: + assert request.json_body is not None + assert request.json_body["announcementID"] == "ord-1" + assert "packages" in request.json_body + return httpx.Response(200, json={"data": {"taskId": 11, "status": "announcement-created"}}) + + fake = ( + AsyncFakeTransport() + .add_json( + "POST", + "/order-management/1/orders/labels", + {"result": {"taskId": 42, "status": "created"}}, + ) + .add( + "GET", + "/order-management/1/orders/labels/42/download", + httpx.Response( + 200, + content=pdf_bytes, + headers={ + "content-type": "application/pdf", + "content-disposition": 'attachment; filename="label-42.pdf"', + }, + ), + ) + .add("POST", "/createAnnouncement", create_announcement) + .add_json("POST", "/createParcel", {"data": {"parcelId": "par-1", "status": "parcel-created"}}) + .add_json("POST", "/cancelAnnouncement", {"data": {"status": "announcement-cancelled"}}) + .add_json( + "POST", + "/delivery/order/changeParcelResult", + {"data": {"status": "callback-accepted"}}, + ) + .add_json("POST", "/sandbox/changeParcels", {"data": {"status": "parcels-updated"}}) + .add_json("GET", "/delivery-sandbox/tasks/51", {"data": {"taskId": 51, "status": "done"}}) + .add_json( + "POST", + "/stock-management/1/info", + { + "stocks": [ + { + "item_id": 123321, + "quantity": 5, + "is_multiple": True, + "is_unlimited": False, + "is_out_of_stock": False, + } + ] + }, + ) + .add_json( + "PUT", + "/stock-management/1/stocks", + {"stocks": [{"item_id": 123321, "external_id": "AB123456", "success": True, "errors": []}]}, + ) + ) + transport = fake.build() + label = AsyncOrderLabel(transport, task_id="42") + delivery = AsyncDeliveryOrder(transport) + task = AsyncDeliveryTask(transport, task_id="51") + stock = AsyncStock(transport) + + assert (await label.create(order_ids=["ord-1"])).task_id == "42" + assert (await label.download()).binary.content == pdf_bytes + assert (await delivery.create_announcement(order_id="ord-1")).task_id == "11" + assert (await delivery.create(order_id="ord-1", parcel_id="par-1")).parcel_id == "par-1" + assert (await delivery.delete(order_id="ord-1")).status == "announcement-cancelled" + assert (await delivery.create_change_parcel_result(parcel_id="par-1", result="ok")).status == "callback-accepted" + assert (await delivery.update_change_parcels(parcel_ids=["par-1"])).status == "parcels-updated" + assert (await task.get()).status == "done" + assert (await stock.get(item_ids=[123321])).items[0].quantity == 5 + assert (await stock.update(stocks=[StockUpdateEntry(item_id=123321, quantity=7)])).items[0].success is True + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_sandbox_delivery_rejects_invalid_event_dates_before_transport() -> None: + fake = AsyncFakeTransport() + transport = fake.build() + delivery = AsyncSandboxDelivery(transport) + + with pytest.raises(ValidationError, match="date"): + await delivery.tracking( + order_id="ord-1", + avito_status="CONFIRMED", + avito_event_type="", + provider_event_code="accepted", + date="not-a-date", + location="Москва", + ) + + assert fake.count() == 0 + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_order_apply_rejects_unknown_transition_before_transport() -> None: + fake = AsyncFakeTransport() + transport = fake.build() + order = AsyncOrder(transport) + + with pytest.raises(ValidationError, match="transition"): + await order.apply(order_id="ord-1", transition="unknown") + + assert fake.count() == 0 + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_orders_factories_return_async_domains() -> None: + client = AsyncFakeTransport().as_client() + + assert isinstance(client.order(), AsyncOrder) + assert isinstance(client.order_label(task_id="42"), AsyncOrderLabel) + assert isinstance(client.delivery_order(), AsyncDeliveryOrder) + assert isinstance(client.sandbox_delivery(), AsyncSandboxDelivery) + assert isinstance(client.delivery_task(task_id="51"), AsyncDeliveryTask) + assert isinstance(client.stock(), AsyncStock) + await client.aclose() diff --git a/tests/domains/promotion/test_promotion_async.py b/tests/domains/promotion/test_promotion_async.py new file mode 100644 index 0000000..363d70a --- /dev/null +++ b/tests/domains/promotion/test_promotion_async.py @@ -0,0 +1,486 @@ +from __future__ import annotations + +from datetime import datetime + +import httpx +import pytest + +from avito.async_client import AsyncAvitoClient +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core import ResponseMappingError, ValidationError +from avito.promotion import ( + AsyncAutostrategyCampaign, + AsyncBbipPromotion, + AsyncCpaAuction, + AsyncPromotionOrder, + AsyncTargetActionPricing, + AsyncTrxPromotion, +) +from avito.promotion.models import ( + BbipItem, + CpaAuctionBidInput, + TrxItem, +) +from avito.testing import AsyncFakeTransport +from avito.testing.fake_transport import RecordedRequest + + +@pytest.mark.asyncio +async def test_async_promotion_service_dictionary_and_orders_flow() -> None: + fake = ( + AsyncFakeTransport() + .add_json( + "POST", + "/promotion/v1/items/services/dict", + {"items": [{"code": "x2", "title": "X2"}]}, + ) + .add_json( + "POST", + "/promotion/v1/items/services/get", + { + "items": [ + { + "itemId": 101, + "serviceCode": "x2", + "serviceName": "X2", + "price": 9900, + "status": "available", + } + ] + }, + ) + .add_json( + "POST", + "/promotion/v1/items/services/orders/get", + { + "items": [ + { + "orderId": "ord-1", + "itemId": 101, + "serviceCode": "x2", + "status": "created", + } + ] + }, + ) + .add_json( + "POST", + "/promotion/v1/items/services/orders/status", + {"orderId": "ord-1", "status": "processed", "items": [], "errors": []}, + ) + ) + transport = fake.build() + promotion = AsyncPromotionOrder(transport, order_id="ord-1") + + assert (await promotion.get_service_dictionary()).items[0].code == "x2" + assert (await promotion.list_services(item_ids=[101])).items[0].price == 9900 + assert (await promotion.list_orders(item_ids=[101])).items[0].order_id == "ord-1" + assert (await promotion.get_order_status()).status == "processed" + assert fake.last(method="POST", path="/promotion/v1/items/services/get").json_body == { + "itemIds": [101] + } + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_bbip_trx_cpa_and_target_action_flows() -> None: + def create_cpa_bids(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"items": [{"itemID": 101, "pricePenny": 1500}]} + return httpx.Response(200, json={"items": [{"itemID": 101, "success": True}]}) + + fake = ( + AsyncFakeTransport() + .add_json( + "POST", + "/promotion/v1/items/services/bbip/forecasts/get", + { + "items": [ + { + "itemId": 101, + "min": 10, + "max": 25, + "totalPrice": 7000, + "totalOldPrice": 8400, + } + ] + }, + ) + .add_json( + "PUT", + "/promotion/v1/items/services/bbip/orders/create", + {"items": [{"itemId": 101, "success": True, "status": "created"}]}, + ) + .add_json( + "POST", + "/promotion/v1/items/services/bbip/suggests/get", + { + "items": [ + { + "itemId": 101, + "duration": {"from": 1, "to": 7, "recommended": 5}, + "budgets": [{"price": 1000, "oldPrice": 1200, "isRecommended": True}], + } + ] + }, + ) + .add_json( + "POST", + "/trx-promo/1/apply", + {"success": {"items": [{"itemID": 101, "success": True}]}}, + ) + .add_json( + "POST", + "/trx-promo/1/cancel", + {"success": {"items": [{"itemID": 101, "success": True}]}}, + ) + .add_json( + "GET", + "/trx-promo/1/commissions", + { + "success": { + "items": [ + { + "itemID": 101, + "commission": 1500, + "isActive": True, + "validCommissionRange": { + "valueMin": 100, + "valueMax": 2000, + "step": 100, + }, + } + ] + } + }, + ) + .add_json( + "GET", + "/auction/1/bids", + { + "items": [ + { + "itemID": 101, + "pricePenny": 1300, + "availablePrices": [{"pricePenny": 1200, "goodness": 1}], + } + ] + }, + ) + .add("POST", "/auction/1/bids", create_cpa_bids) + .add_json( + "GET", + "/cpxpromo/1/getBids/101", + { + "actionTypeID": 5, + "selectedType": "manual", + "manual": { + "bidPenny": 1400, + "limitPenny": 15000, + "recBidPenny": 1500, + "minBidPenny": 1000, + "maxBidPenny": 2000, + "minLimitPenny": 5000, + "maxLimitPenny": 50000, + "bids": [{"valuePenny": 1500, "minForecast": 2, "maxForecast": 5}], + }, + }, + ) + .add_json( + "POST", + "/cpxpromo/1/getPromotionsByItemIds", + { + "items": [ + { + "itemID": 102, + "actionTypeID": 7, + "autoPromotion": {"budgetPenny": 9000, "budgetType": "7d"}, + } + ] + }, + ) + .add_json( + "POST", + "/cpxpromo/1/remove", + {"items": [{"itemID": 101, "success": True, "status": "removed"}]}, + ) + .add_json( + "POST", + "/cpxpromo/1/setAuto", + {"items": [{"itemID": 101, "success": True, "status": "auto"}]}, + ) + .add_json( + "POST", + "/cpxpromo/1/setManual", + {"items": [{"itemID": 101, "success": True, "status": "manual"}]}, + ) + ) + transport = fake.build() + bbip = AsyncBbipPromotion(transport, item_id=101) + trx = AsyncTrxPromotion(transport, item_id=101) + auction = AsyncCpaAuction(transport) + pricing = AsyncTargetActionPricing(transport, item_id=101) + bbip_item = BbipItem(item_id=101, duration=7, price=1000, old_price=1200) + trx_item = TrxItem( + item_id=101, + commission=1500, + date_from=datetime.fromisoformat("2026-04-18T00:00:00+00:00"), + ) + + assert (await bbip.get_forecasts(items=[bbip_item])).items[0].max_views == 25 + assert (await bbip.create_order(items=[bbip_item])).status == "created" + assert (await bbip.get_suggests()).items[0].duration is not None + assert (await trx.apply(items=[trx_item])).applied is True + assert (await trx.delete()).applied is True + assert (await trx.get_commissions()).items[0].valid_commission_range is not None + assert (await auction.get_user_bids(from_item_id=100, batch_size=50)).items[0].available_prices[ + 0 + ].price_penny == 1200 + assert ( + await auction.create_item_bids(items=[CpaAuctionBidInput(item_id=101, price_penny=1500)]) + ).applied is True + assert (await pricing.get_bids()).manual is not None + assert (await pricing.get_promotions_by_item_ids(item_ids=[101, 102])).items[0].auto is not None + assert (await pricing.delete()).status == "removed" + assert ( + await pricing.update_auto(action_type_id=5, budget_penny=8000, budget_type="7d") + ).status == "auto" + assert (await pricing.update_manual(action_type_id=5, bid_penny=1500)).status == "manual" + assert fake.last(method="GET", path="/auction/1/bids").params == { + "fromItemID": "100", + "batchSize": "50", + } + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_autostrategy_flows() -> None: + fake = ( + AsyncFakeTransport() + .add_json( + "POST", + "/autostrategy/v1/budget", + { + "calcId": 501, + "budget": { + "recommended": {"total": 10100}, + "minimal": {"total": 5100}, + "priceRanges": [], + }, + }, + ) + .add_json( + "POST", + "/autostrategy/v1/campaign/create", + {"campaign": {"campaignId": 77, "campaignType": "AS", "version": 3}}, + ) + .add_json( + "POST", + "/autostrategy/v1/campaign/edit", + {"campaign": {"campaignId": 77, "campaignType": "AS", "version": 4}}, + ) + .add_json( + "POST", + "/autostrategy/v1/campaign/info", + { + "campaign": { + "campaignId": 77, + "campaignType": "AS", + "statusId": 1, + "budget": 10000, + "balance": 9000, + "title": "Весенняя кампания", + "version": 4, + }, + "forecast": {"calls": {"from": 2, "to": 5}, "views": {"from": 30, "to": 50}}, + "items": [{"itemId": 101, "isActive": True}], + }, + ) + .add_json( + "POST", + "/autostrategy/v1/campaign/stop", + {"campaign": {"campaignId": 77, "campaignType": "AS", "version": 5}}, + ) + .add_json( + "POST", + "/autostrategy/v1/campaigns", + { + "campaigns": [{"campaignId": 77, "campaignType": "AS", "statusId": 1}], + "totalCount": 1, + }, + ) + .add_json( + "POST", + "/autostrategy/v1/stat", + {"stat": [{"date": "2026-04-18", "calls": 30}], "totals": {"calls": 30}}, + ) + ) + transport = fake.build() + campaign = AsyncAutostrategyCampaign(transport, campaign_id=77) + start_time = datetime.fromisoformat("2026-04-20T00:00:00+00:00") + finish_time = datetime.fromisoformat("2026-04-27T00:00:00+00:00") + + assert ( + await campaign.create_budget( + campaign_type="AS", + start_time=start_time, + finish_time=finish_time, + items=[101, 102], + ) + ).calc_id == 501 + assert ( + await campaign.create( + campaign_type="AS", + title="Весенняя кампания", + budget=10000, + calc_id=501, + items=[101, 102], + start_time=start_time, + finish_time=finish_time, + ) + ).campaign is not None + assert (await campaign.update(campaign_id=77, version=3, title="Обновленная кампания")).campaign + assert (await campaign.get()).campaign is not None + assert (await campaign.delete(version=4)).campaign is not None + assert ( + await campaign.list( + limit=20, + offset=10, + status_id=[1, 2], + order_by=[("startTime", "asc")], + updated_from=datetime.fromisoformat("2026-04-01T00:00:00+00:00"), + updated_to=datetime.fromisoformat("2026-04-30T00:00:00+00:00"), + ) + ).total_count == 1 + assert (await campaign.get_stat()).totals is not None + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_promotion_dry_run_does_not_call_transport() -> None: + fake = AsyncFakeTransport() + transport = fake.build() + bbip = AsyncBbipPromotion(transport, item_id=101) + trx = AsyncTrxPromotion(transport, item_id=101) + pricing = AsyncTargetActionPricing(transport, item_id=101) + bbip_item = BbipItem(item_id=101, duration=7, price=1000, old_price=1200) + trx_item = TrxItem( + item_id=101, + commission=1500, + date_from=datetime.fromisoformat("2026-04-18T00:00:00+00:00"), + ) + + assert (await bbip.create_order(items=[bbip_item], dry_run=True)).status == "preview" + assert (await trx.apply(items=[trx_item], dry_run=True)).status == "preview" + assert ( + await pricing.update_manual(action_type_id=5, bid_penny=1500, dry_run=True) + ).status == "preview" + assert fake.requests == [] + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_autostrategy_datetime_parameters_fail_fast_on_invalid_type() -> None: + transport = AsyncFakeTransport().build() + campaign = AsyncAutostrategyCampaign(transport, campaign_id=77) + + with pytest.raises(ValidationError, match="`start_time` должен быть datetime."): + await campaign.create_budget(campaign_type="AS", start_time="2026-04-20T00:00:00+00:00") # type: ignore[arg-type] + with pytest.raises(ValidationError, match="`finish_time` должен быть datetime."): + await campaign.create( + campaign_type="AS", + title="Весенняя кампания", + finish_time="2026-04-27T00:00:00+00:00", # type: ignore[arg-type] + ) + with pytest.raises(ValidationError, match="`start_time` должен быть datetime."): + await campaign.update( + version=3, + start_time="2026-04-20T00:00:00+00:00", # type: ignore[arg-type] + ) + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_idempotency_key_forwarded_once_per_retry_chain() -> None: + seen_keys: list[str | None] = [] + + def fail_once(request: RecordedRequest) -> httpx.Response: + seen_keys.append(request.headers.get("idempotency-key")) + raise httpx.ConnectError( + "offline", + request=httpx.Request("POST", "https://api.avito.ru/cpxpromo/1/setManual"), + ) + + def succeed(request: RecordedRequest) -> httpx.Response: + seen_keys.append(request.headers.get("idempotency-key")) + return httpx.Response( + 200, + json={"items": [{"itemID": 101, "success": True, "status": "manual"}]}, + ) + + fake = AsyncFakeTransport().add("POST", "/cpxpromo/1/setManual", fail_once, succeed) + transport = fake.build() + pricing = AsyncTargetActionPricing(transport, item_id=101) + + result = await pricing.update_manual( + action_type_id=5, + bid_penny=1500, + idempotency_key="idem-123", + ) + + assert result.status == "manual" + assert seen_keys == ["idem-123", "idem-123"] + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_promotion_read_mappers_raise_on_invalid_shape() -> None: + fake = ( + AsyncFakeTransport() + .add_json("POST", "/promotion/v1/items/services/orders/status", {"items": []}) + .add_json("GET", "/cpxpromo/1/getBids/101", {"items": []}) + .add_json("POST", "/cpxpromo/1/getPromotionsByItemIds", {"items": [{"itemID": 102}]}) + ) + transport = fake.build() + + with pytest.raises(ResponseMappingError): + await AsyncPromotionOrder(transport, order_id="ord-2").get_order_status() + with pytest.raises(ResponseMappingError): + await AsyncTargetActionPricing(transport, item_id=101).get_bids() + with pytest.raises(ResponseMappingError): + await AsyncTargetActionPricing(transport, item_id=101).get_promotions_by_item_ids( + item_ids=[102] + ) + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_promotion_factories_return_async_domains() -> None: + client = AsyncFakeTransport().as_client() + + assert isinstance(client.promotion_order(order_id="ord-1"), AsyncPromotionOrder) + assert isinstance(client.bbip_promotion(item_id=101), AsyncBbipPromotion) + assert isinstance(client.trx_promotion(item_id=101), AsyncTrxPromotion) + assert isinstance(client.cpa_auction(item_id=101), AsyncCpaAuction) + assert isinstance(client.target_action_pricing(item_id=101), AsyncTargetActionPricing) + assert isinstance(client.autostrategy_campaign(campaign_id=77), AsyncAutostrategyCampaign) + await client.aclose() + + +def test_async_client_promotion_factories_require_entered_client() -> None: + client = AsyncAvitoClient( + AvitoSettings(auth=AuthSettings(client_id="id", client_secret="secret")) + ) + + with pytest.raises(RuntimeError): + client.promotion_order() + with pytest.raises(RuntimeError): + client.bbip_promotion() + with pytest.raises(RuntimeError): + client.trx_promotion() + with pytest.raises(RuntimeError): + client.cpa_auction() + with pytest.raises(RuntimeError): + client.target_action_pricing() + with pytest.raises(RuntimeError): + client.autostrategy_campaign() diff --git a/tests/domains/ratings/test_ratings_async.py b/tests/domains/ratings/test_ratings_async.py new file mode 100644 index 0000000..41ffdc8 --- /dev/null +++ b/tests/domains/ratings/test_ratings_async.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import httpx +import pytest + +from avito.async_client import AsyncAvitoClient +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core.exceptions import ( + AuthenticationError, + RateLimitError, + TransportError, + ValidationError, +) +from avito.core.retries import RetryPolicy +from avito.ratings import AsyncRatingProfile, AsyncReview, AsyncReviewAnswer +from avito.testing import AsyncFakeTransport +from avito.testing.fake_transport import RecordedRequest + + +def _reviews_payload() -> dict[str, object]: + return { + "total": 25, + "reviews": [ + { + "id": 123, + "score": 5, + "stage": "done", + "text": "Все отлично", + "createdAt": 1713427200, + "canAnswer": True, + "usedInScore": True, + } + ], + } + + +def _rating_payload() -> dict[str, object]: + return { + "isEnabled": True, + "rating": {"score": 4.7, "reviewsCount": 25, "reviewsWithScoreCount": 20}, + } + + +@pytest.mark.asyncio +async def test_async_ratings_flows() -> None: + def create_answer(request: RecordedRequest) -> httpx.Response: + assert request.json_body == {"reviewId": 123, "message": "Спасибо за отзыв"} + return httpx.Response(200, json={"id": 456, "createdAt": 1713427200}) + + fake = ( + AsyncFakeTransport() + .add("POST", "/ratings/v1/answers", create_answer) + .add_json("DELETE", "/ratings/v1/answers/456", {"success": True}) + .add_json("GET", "/ratings/v1/info", _rating_payload()) + .add_json("GET", "/ratings/v1/reviews", _reviews_payload()) + ) + transport = fake.build() + + answer = AsyncReviewAnswer(transport, answer_id="456") + profile = AsyncRatingProfile(transport) + review = AsyncReview(transport) + + assert (await answer.create(review_id=123, text="Спасибо за отзыв")).answer_id == "456" + assert (await answer.delete()).success is True + assert (await profile.get()).score == 4.7 + assert (await review.list(page=2)).items[0].text == "Все отлично" + assert fake.last(method="GET", path="/ratings/v1/reviews").params["page"] == "2" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_review_list_uses_working_default_page() -> None: + fake = AsyncFakeTransport().add_json("GET", "/ratings/v1/reviews", {"reviews": []}) + transport = fake.build() + + assert (await AsyncReview(transport).list()).items == [] + request = fake.last(method="GET", path="/ratings/v1/reviews") + assert request.params["page"] == "1" + assert request.params["limit"] == "50" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_ratings_factories_return_async_domains() -> None: + fake = ( + AsyncFakeTransport() + .add_json("GET", "/ratings/v1/reviews", _reviews_payload()) + .add_json("POST", "/ratings/v1/answers", {"id": 456, "createdAt": 1713427200}) + .add_json("GET", "/ratings/v1/info", _rating_payload()) + ) + client = fake.as_client() + + review = client.review() + answer = client.review_answer() + profile = client.rating_profile() + + assert isinstance(review, AsyncReview) + assert isinstance(answer, AsyncReviewAnswer) + assert isinstance(profile, AsyncRatingProfile) + assert (await review.list()).total == 25 + assert (await answer.create(review_id=123, text="Спасибо")).answer_id == "456" + assert (await profile.get()).reviews_count == 25 + await client.aclose() + + +@pytest.mark.asyncio +async def test_async_review_answer_delete_requires_answer_id() -> None: + transport = AsyncFakeTransport().build() + + with pytest.raises(ValidationError): + await AsyncReviewAnswer(transport).delete() + + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_ratings_maps_401() -> None: + fake = AsyncFakeTransport().add_json( + "GET", + "/ratings/v1/info", + {"error": "unauthorized"}, + status_code=401, + ) + transport = fake.build() + + with pytest.raises(AuthenticationError): + await AsyncRatingProfile(transport).get() + + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_ratings_maps_429() -> None: + fake = AsyncFakeTransport().add_json( + "GET", + "/ratings/v1/info", + {"error": "rate limit"}, + status_code=429, + ) + transport = fake.build(retry_policy=RetryPolicy(max_attempts=1)) + + with pytest.raises(RateLimitError): + await AsyncRatingProfile(transport).get() + + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_ratings_maps_transport_error() -> None: + def raise_network_error(request: object) -> httpx.Response: + raise httpx.NetworkError("connection failed") + + fake = AsyncFakeTransport().add("GET", "/ratings/v1/info", raise_network_error) + transport = fake.build(retry_policy=RetryPolicy(max_attempts=1)) + + with pytest.raises(TransportError): + await AsyncRatingProfile(transport).get() + + await transport.aclose() + + +def test_async_client_ratings_factories_require_entered_client() -> None: + client = AsyncAvitoClient( + AvitoSettings(auth=AuthSettings(client_id="id", client_secret="secret")) + ) + + with pytest.raises(RuntimeError): + client.review() + with pytest.raises(RuntimeError): + client.review_answer() + with pytest.raises(RuntimeError): + client.rating_profile() diff --git a/tests/domains/realty/test_realty_async.py b/tests/domains/realty/test_realty_async.py new file mode 100644 index 0000000..69da33e --- /dev/null +++ b/tests/domains/realty/test_realty_async.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import pytest + +from avito.async_client import AsyncAvitoClient +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core import ValidationError +from avito.realty import ( + AsyncRealtyAnalyticsReport, + AsyncRealtyBooking, + AsyncRealtyListing, + AsyncRealtyPricing, +) +from avito.realty.models import RealtyInterval, RealtyPricePeriod +from avito.testing import AsyncFakeTransport +from avito.testing.fake_transport import RecordedRequest + + +@pytest.mark.asyncio +async def test_async_realty_maps_bookings_pricing_listing_and_analytics() -> None: + def update_bookings(request: RecordedRequest): + assert request.json_body == { + "bookings": [{"date_start": "2026-04-18", "date_end": "2026-04-18"}] + } + return _success_response() + + def update_prices(request: RecordedRequest): + assert request.json_body == { + "prices": [{"date_from": "2026-05-01", "night_price": 5000}] + } + return _success_response() + + def get_intervals(request: RecordedRequest): + assert request.json_body == { + "item_id": 20, + "intervals": [{"date_start": "2026-05-01", "date_end": "2026-05-01", "open": 1}], + } + return _success_response() + + def update_base(request: RecordedRequest): + assert request.json_body == {"minimal_duration": 2} + return _success_response() + + fake = ( + AsyncFakeTransport() + .add("POST", "/core/v1/accounts/10/items/20/bookings", update_bookings) + .add_json( + "GET", + "/realty/v1/accounts/10/items/20/bookings", + { + "bookings": [ + { + "avito_booking_id": 777, + "status": "active", + "check_in": "2026-05-01", + "check_out": "2026-05-05", + "guest_count": 2, + "nights": 4, + "base_price": 12000, + "contact": { + "name": "Иван", + "email": "ivan@example.com", + "phone": "9997770000", + }, + "safe_deposit": { + "owner_amount": 4500, + "tax": 500, + "total_amount": 5000, + }, + } + ] + }, + ) + .add("POST", "/realty/v1/accounts/10/items/20/prices", update_prices) + .add("POST", "/realty/v1/items/intervals", get_intervals) + .add("POST", "/realty/v1/items/20/base", update_base) + .add_json( + "GET", + "/realty/v1/marketPriceCorrespondence/20/5000000", + {"correspondence": "normal"}, + ) + .add_json( + "POST", + "/realty/v1/report/create/20", + {"success": {"success": {"reportLink": "https://example.com/realty-report/20"}}}, + ) + ) + transport = fake.build() + booking = AsyncRealtyBooking(transport, item_id="20", user_id="10") + pricing = AsyncRealtyPricing(transport, item_id="20", user_id="10") + listing = AsyncRealtyListing(transport, item_id="20") + analytics = AsyncRealtyAnalyticsReport(transport, item_id="20") + + assert (await booking.update_bookings_info(blocked_dates=["2026-04-18"])).success is True + bookings = await booking.list_realty_bookings( + date_start="2026-05-01", + date_end="2026-05-05", + with_unpaid=True, + ) + assert bookings.items[0].contact is not None + assert bookings.items[0].contact.name == "Иван" + assert ( + await pricing.update_realty_prices( + periods=[RealtyPricePeriod(date_from="2026-05-01", price=5000)] + ) + ).status == "success" + assert ( + await listing.get_intervals(intervals=[RealtyInterval(date="2026-05-01", available=True)]) + ).success is True + assert (await listing.update_base_params(min_stay_days=2)).success is True + assert (await analytics.get_market_price_correspondence(price=5000000)).correspondence == "normal" + assert ( + await analytics.get_report_for_classified() + ).report_link == "https://example.com/realty-report/20" + assert fake.last(method="GET", path="/realty/v1/accounts/10/items/20/bookings").params == { + "date_start": "2026-05-01", + "date_end": "2026-05-05", + "with_unpaid": "true", + } + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_realty_write_operation_forwards_idempotency_key() -> None: + def update_prices(request: RecordedRequest): + assert request.headers["idempotency-key"] == "idem-realty-prices" + assert request.json_body == { + "prices": [{"date_from": "2026-05-01", "night_price": 5000}] + } + return _success_response() + + fake = AsyncFakeTransport().add( + "POST", + "/realty/v1/accounts/10/items/20/prices", + update_prices, + ) + transport = fake.build() + pricing = AsyncRealtyPricing(transport, item_id="20", user_id="10") + + result = await pricing.update_realty_prices( + periods=[RealtyPricePeriod(date_from="2026-05-01", price=5000)], + idempotency_key="idem-realty-prices", + ) + + assert result.status == "success" + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_realty_rejects_invalid_dates_before_transport() -> None: + transport = AsyncFakeTransport().build() + booking = AsyncRealtyBooking(transport, item_id="20", user_id="10") + pricing = AsyncRealtyPricing(transport, item_id="20", user_id="10") + listing = AsyncRealtyListing(transport, item_id="20") + + with pytest.raises(ValidationError, match="date_start"): + await booking.list_realty_bookings(date_start="01.05.2026", date_end="2026-05-05") + with pytest.raises(ValidationError, match="blocked_dates"): + await booking.update_bookings_info(blocked_dates=["not-a-date"]) + with pytest.raises(ValidationError, match="date_from"): + await pricing.update_realty_prices( + periods=[RealtyPricePeriod(date_from="not-a-date", price=5000)] + ) + with pytest.raises(ValidationError, match="date"): + await listing.get_intervals(intervals=[RealtyInterval(date="not-a-date", available=True)]) + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_realty_factories_return_async_domains() -> None: + client = AsyncFakeTransport().as_client() + + assert isinstance(client.realty_listing(item_id=20, user_id=10), AsyncRealtyListing) + assert isinstance(client.realty_booking(item_id=20, user_id=10), AsyncRealtyBooking) + assert isinstance(client.realty_pricing(item_id=20, user_id=10), AsyncRealtyPricing) + assert isinstance( + client.realty_analytics_report(item_id=20, user_id=10), + AsyncRealtyAnalyticsReport, + ) + await client.aclose() + + +def test_async_client_realty_factories_require_entered_client() -> None: + client = AsyncAvitoClient( + AvitoSettings(auth=AuthSettings(client_id="id", client_secret="secret")) + ) + + with pytest.raises(RuntimeError): + client.realty_listing() + with pytest.raises(RuntimeError): + client.realty_booking() + with pytest.raises(RuntimeError): + client.realty_pricing() + with pytest.raises(RuntimeError): + client.realty_analytics_report() + + +def _success_response(): + import httpx + + return httpx.Response(200, json={"result": "success"}) diff --git a/tests/domains/tariffs/test_tariffs_async.py b/tests/domains/tariffs/test_tariffs_async.py new file mode 100644 index 0000000..2e49018 --- /dev/null +++ b/tests/domains/tariffs/test_tariffs_async.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import httpx +import pytest + +from avito.async_client import AsyncAvitoClient +from avito.auth.settings import AuthSettings +from avito.config import AvitoSettings +from avito.core.exceptions import AuthenticationError, RateLimitError, TransportError +from avito.core.retries import RetryPolicy +from avito.tariffs import AsyncTariff +from avito.testing import AsyncFakeTransport + + +def _tariff_payload() -> dict[str, object]: + return { + "current": { + "level": "Тариф Максимальный", + "isActive": True, + "startTime": 1713427200, + "closeTime": 1716029200, + "bonus": 10, + "packages": [{"id": 1}, {"id": 2}], + "price": {"price": 1990, "originalPrice": 2490}, + }, + "scheduled": { + "level": "Тариф Базовый", + "isActive": False, + "startTime": 1716029300, + "closeTime": None, + "bonus": 0, + "packages": [], + "price": {"price": 990, "originalPrice": 990}, + }, + } + + +@pytest.mark.asyncio +async def test_async_tariff_flow() -> None: + fake = AsyncFakeTransport().add_json("GET", "/tariff/info/1", _tariff_payload()) + transport = fake.build() + + tariff = AsyncTariff(transport) + info = await tariff.get_tariff_info() + + assert info.current is not None + assert info.current.level == "Тариф Максимальный" + assert info.current.packages_count == 2 + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_client_tariff_factory_returns_async_tariff() -> None: + fake = AsyncFakeTransport().add_json("GET", "/tariff/info/1", _tariff_payload()) + client = fake.as_client() + + tariff = client.tariff() + info = await tariff.get_tariff_info() + + assert isinstance(tariff, AsyncTariff) + assert info.scheduled is not None + assert info.scheduled.level == "Тариф Базовый" + await client.aclose() + + +@pytest.mark.asyncio +async def test_async_tariff_maps_401() -> None: + fake = AsyncFakeTransport().add_json( + "GET", + "/tariff/info/1", + {"error": "unauthorized"}, + status_code=401, + ) + transport = fake.build() + + with pytest.raises(AuthenticationError): + await AsyncTariff(transport).get_tariff_info() + + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_tariff_maps_429() -> None: + fake = AsyncFakeTransport().add_json( + "GET", + "/tariff/info/1", + {"error": "rate limit"}, + status_code=429, + ) + transport = fake.build(retry_policy=RetryPolicy(max_attempts=1)) + + with pytest.raises(RateLimitError): + await AsyncTariff(transport).get_tariff_info() + + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_tariff_maps_transport_error() -> None: + def raise_network_error(request: object) -> httpx.Response: + raise httpx.NetworkError("connection failed") + + fake = AsyncFakeTransport().add("GET", "/tariff/info/1", raise_network_error) + transport = fake.build(retry_policy=RetryPolicy(max_attempts=1)) + + with pytest.raises(TransportError): + await AsyncTariff(transport).get_tariff_info() + + await transport.aclose() + + +def test_async_client_tariff_requires_entered_client() -> None: + client = AsyncAvitoClient( + AvitoSettings(auth=AuthSettings(client_id="id", client_secret="secret")) + ) + + with pytest.raises(RuntimeError): + client.tariff() diff --git a/tests/test_async_client_aggregators.py b/tests/test_async_client_aggregators.py new file mode 100644 index 0000000..7e751be --- /dev/null +++ b/tests/test_async_client_aggregators.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import pytest + +from avito.summary import AccountHealthSummary, ListingHealthSummary, PromotionSummary +from avito.testing import AsyncFakeTransport, FanoutPeakRecorder + + +def _summary_fake(*, recorder: FanoutPeakRecorder) -> AsyncFakeTransport: + return ( + AsyncFakeTransport(fanout_recorder=recorder) + .add_json( + "GET", + "/core/v1/accounts/7/balance/", + {"user_id": 7, "real": 100, "bonus": 25, "total": 125}, + ) + .add_json( + "GET", + "/core/v1/items", + {"items": [{"id": 101, "title": "Смартфон", "status": "active"}], "total": 1}, + ) + .add_json( + "POST", + "/stats/v1/accounts/7/items", + {"items": [{"item_id": 101, "views": 10}]}, + ) + .add_json( + "POST", + "/core/v1/accounts/7/calls/stats/", + {"items": [{"item_id": 101, "calls": 2}]}, + ) + .add_json( + "POST", + "/stats/v2/accounts/7/spendings", + {"items": [{"item_id": 101, "amount": 15.5}]}, + ) + .add_json( + "GET", + "/messenger/v2/accounts/7/chats", + {"chats": [{"id": "c1", "unreadCount": 4}, {"id": "c2", "unreadCount": 0}]}, + ) + .add_json( + "GET", + "/order-management/1/orders", + {"orders": [{"id": "o1", "status": "new"}, {"id": "o2", "status": "unknown"}]}, + ) + .add_json( + "GET", + "/ratings/v1/reviews", + { + "total": 2, + "reviews": [ + {"id": 1, "score": 5, "canAnswer": True}, + {"id": 2, "score": 3, "canAnswer": False}, + ], + }, + ) + .add_json("GET", "/ratings/v1/info", {"isEnabled": True, "rating": {"score": 4.5}}) + .add_json( + "POST", + "/promotion/v1/items/services/orders/get", + {"orders": [{"orderId": "p1", "status": "applied"}]}, + ) + .add_json( + "POST", + "/promotion/v1/items/services/get", + {"services": [{"itemId": 101, "status": "available"}]}, + ) + ) + + +@pytest.mark.asyncio +async def test_account_health_fanout_does_not_exceed_six() -> None: + recorder = FanoutPeakRecorder() + client = _summary_fake(recorder=recorder).as_client(user_id=7) + + summary = await client.account_health() + + assert isinstance(summary, AccountHealthSummary) + assert summary.balance_total == 125 + assert recorder.peak <= 6 + await client.aclose() + + +@pytest.mark.asyncio +async def test_listing_health_fanout_does_not_exceed_three() -> None: + recorder = FanoutPeakRecorder() + client = _summary_fake(recorder=recorder).as_client(user_id=7) + + summary = await client.listing_health() + + assert isinstance(summary, ListingHealthSummary) + assert summary.total_views == 10 + assert recorder.peak <= 3 + await client.aclose() + + +@pytest.mark.asyncio +async def test_review_summary_is_sequential() -> None: + recorder = FanoutPeakRecorder() + client = _summary_fake(recorder=recorder).as_client(user_id=7) + + summary = await client.review_summary() + + assert summary.average_score == 4 + assert recorder.peak <= 1 + await client.aclose() + + +@pytest.mark.asyncio +async def test_promotion_summary_with_items_fanout_does_not_exceed_two() -> None: + recorder = FanoutPeakRecorder() + client = _summary_fake(recorder=recorder).as_client(user_id=7) + + summary = await client.promotion_summary(item_ids=[101]) + + assert isinstance(summary, PromotionSummary) + assert summary.available_services == 1 + assert recorder.peak <= 2 + await client.aclose() + + +@pytest.mark.asyncio +async def test_business_summary_delegates_to_account_health_fanout() -> None: + recorder = FanoutPeakRecorder() + client = _summary_fake(recorder=recorder).as_client(user_id=7) + + summary = await client.business_summary() + + assert isinstance(summary, AccountHealthSummary) + assert summary.orders is not None + assert recorder.peak <= 6 + await client.aclose() diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..3d59d1e --- /dev/null +++ b/todo.md @@ -0,0 +1,1691 @@ +# Dual-mode SDK (sync + async) + +## Context + +The SDK is currently fully synchronous: `AvitoClient` → `Transport` (`httpx.Client` + `time.sleep`) → +`AuthProvider` (`TokenClient` on top of sync-transport) → `DomainObject` subclasses +(11 API packages + auth-bindings, 204 swagger operations) → `PaginatedList[T]` +(subclass of `list`). The goal is to add a second, +asynchronous, surface modeled after `httpx.Client`/`httpx.AsyncClient`, without breaking the sync API, +reusing `OperationSpec`, models, request/query DTOs, swagger invariants, and +errors. + +## Decisions made + +| Question | Decision | +|---|---| +| Style | Parallel classes by hand: next to each sync layer we place an `Async*` class. We do not use codegen. | +| Placement | `avito//async_domain.py` next to `domain.py`. | +| Swagger binding | `@swagger_operation(..., variant="sync"\|"async")`. The linter's unique key is `(operation_key, variant)`. | +| Normative documents | M1 updates `STYLEGUIDE.md`, because right now it describes the SDK as sync-only and only allows `domain.py`. Without this, M1 conflicts with the main style gate. | +| Sequencing | M1 — foundation with tests and async auth-bindings; M2-PoC — proof-of-concept of the template on `tariffs` (foundation validation, may return feedback); M3…M12 — closing each domain in a separate PR to 100%; M-final — convenience methods and release. Until the first domain `Async` class appears, strict-coverage by `variant="async"` for API domains is empty and does not fail; auth is gated separately by `AsyncTokenClient` / `AsyncAlternateTokenClient`. | +| Pagination | `AsyncPaginatedList[ItemT]` — a separate class (not a subclass of `list`), without list-API parity (only `__aiter__` / `materialize` / `loaded_count` / `is_materialized` / `known_total` / `source_total`). | + +## Architecture: what is shared, what is duplicated + +``` + ┌────────── shared (semantics unchanged) ────────────────────┐ + │ │ + │ OperationSpec, models, request/query DTO, ApiTimeouts, │ + │ RequestContext, JsonPage, exceptions, RetryPolicy, │ + │ RateLimiter ("how long to wait" logic), retries.RetryDecision│ + │ │ + └─────────────────────┬──────────────────────────────────────┘ + │ used by both + ┌───────────────┴───────────────┐ + ▼ ▼ + ┌──────── SYNC (as is) ──────┐ ┌──────── ASYNC (new) ───────┐ + │ Transport │ │ AsyncTransport │ + │ ↓ httpx.Client │ │ ↓ httpx.AsyncClient │ + │ ↓ time.sleep │ │ ↓ asyncio.sleep │ + │ OperationExecutor │ │ AsyncOperationExecutor │ + │ AuthProvider/TokenClient │ │ AsyncAuthProvider/ │ + │ │ │ AsyncTokenClient/ │ + │ │ │ AsyncAlternateTokenClient│ + │ PaginatedList[T] (list-sub)│ │ AsyncPaginatedList[T] │ + │ DomainObject │ │ AsyncDomainObject │ + │ ├─ Account │ │ ├─ AsyncAccount │ + │ ├─ Ad … │ │ ├─ AsyncAd … │ + │ AvitoClient │ │ AsyncAvitoClient │ + └────────────────────────────┘ └────────────────────────────┘ + + Swagger binding: variant="sync" variant="async" + ↓ ↓ + swagger_discovery + linter + (per-variant keys) +``` + +To keep retry logic and error mapping from drifting, we extract IO-agnostic +computations into `avito/core/_transport_shared.py` (no httpx call and no sleep): +`_decide_transport_retry`, +`_decide_http_retry`, `_is_retryable_request`, `_get_retry_after_seconds`, `_map_http_error`, +`_safe_payload`, `_extract_message`, `_extract_error_code`, `_extract_error_details`, +`_extract_request_id`, `_normalize_path`, `_normalize_params`, `_normalize_files`, +`_merge_headers`, `_build_user_agent`, `_extract_filename`, `build_httpx_timeout`, +`_safe_endpoint`, `_log_http_exchange`, `_log_retry`, `_elapsed_ms`, +`RateLimitState` (pure token-bucket state with `compute_delay()`/`observe_response()`, +without `Lock` and without `sleep` — see the "Contract for shared parts of RateLimiter" block below). +`Transport` and `AsyncTransport` remain thin wrappers with three differences: +the form of sleep, the form of client.request, and the type of lock around `RateLimitState` +(`threading.Lock` vs `asyncio.Lock`). + +**Retry-loop contract in both modes.** The catch block in `Transport.request()` / +`AsyncTransport.request()` catches only explicitly retryable transport exceptions. +For M1 this mirrors the current sync behavior: `httpx.TimeoutException` and +`httpx.NetworkError`. Expanding catch to all of `httpx.RequestError` cannot be done +silently: it changes sync semantics and is only possible as a separate deliberate +behavior PR with tests. `BaseException` (including `asyncio.CancelledError`, +`KeyboardInterrupt`, `SystemExit`) **never goes into retry** — it is propagated +outwards unmodified. This is critical for async: otherwise the SDK would catch +coroutine cancellation and try to retry it, breaking cancellation semantics. Locked in +by the test `tests/core/test_async_transport.py::test_cancelled_error_is_not_retried` and +a sync baseline-diff in M1. + +**Important clarification about `_merge_headers`.** The current implementation +(`avito/core/transport.py:410-428`) internally makes a synchronous call +`self._auth_provider.get_access_token()` — i.e. it couples token retrieval with merge. +To make the helper IO-agnostic, we refactor its contract: the shared `_merge_headers` +takes an already-resolved `bearer_token: str | None`, while resolution (including `await` in +the async variant) is performed by `Transport`/`AsyncTransport` themselves separately. This is the first step +of Phase 1 (without behavioral changes to sync), and it is blocking for everything else in M1. + +Similarly: `avito/auth/_cache.py` contains in-memory state (fields `_access_token`, +`_refresh_token`, `_autoteka_access_token`) and pure helpers (`_is_token_fresh`). +The module-level function `_map_token_response` (`avito/auth/provider.py:35`) moves +to `_cache.py` without changing its signature. `AuthProvider` and `AsyncAuthProvider` +delegate to the cache and only add the sync/async lock + IO themselves. + +### Dependency order in M1 + +``` + Phase 0 pre-flight (see "Pre-flight for PR M1" section) + ↓ + Phase 1a refactor Transport._merge_headers → accepts a resolved bearer_token + (sync without behavioral changes; baseline pass/fail of tests is identical) + ↓ + Phase 1b _transport_shared.py ◀── the rest of the IO-agnostic extract from Transport + _cache.py ◀── TokenCache + map_token_response, AuthProvider + stores TokenCache + property-shims for + _access_token/_refresh_token/_autoteka_access_token + (for the sake of existing tests) + ↓ + Phase 2 AsyncTransport, AsyncOperationTransport, AsyncOperationExecutor + AsyncAuthProvider (with asyncio.Lock on refresh + a separate autoteka lock) + AsyncTokenClient, AsyncAlternateTokenClient + AsyncPaginatedList, AsyncPaginator + ↓ + Phase 3 variant="async" in the swagger decorator/discovery/linter + AsyncAvitoClient (no factory methods; lifecycle only) + avito/testing/async_fake_transport.py + tests/async_fake_transport.py + (re-export with DeprecationWarning) + ↓ + Phase 4 tests + docs (including baseline-diff proving sync is unchanged) +``` + +## Key files and join points + +### Existing, modified in M1 + +| File | What we change | +|---|---| +| `avito/core/transport.py` | Extract IO-agnostic helpers into `_transport_shared.py` and reuse them. Sync behavior is unchanged. | +| `avito/core/operations.py` | + `AsyncOperationTransport` (Protocol, async mirror of `OperationTransport`), + `AsyncOperationExecutor` (async mirror of `OperationExecutor.execute`) with the same `json` / `empty` / `binary` branches as sync. Helpers `render_path`, `_serialize_query`, `_serialize_request`, `_merge_content_type`, `_extract_filename` are already module-level — reused without copies. | +| `avito/core/swagger.py` | + a `variant: Literal["sync","async"] = "sync"` field on `SwaggerOperationBinding`. + a `variant` parameter on `swagger_operation(...)`. The `ConfigurationError` on double-decorating one function — unchanged. | +| `avito/core/swagger_discovery.py` | `_iter_domain_modules` additionally looks for `.async_domain` (next to `.domain`). `DiscoveredSwaggerBinding` gets `variant`. `canonical_map` remains a sync-only compatibility API for existing sync contract tests; the new `canonical_map_by_variant` / `binding_for(operation_key, variant)` uses the key `(operation_key, variant)`. | +| `avito/core/swagger_linter.py` | `_validate_duplicate_bindings` groups by `(operation_key, variant)`. `_validate_complete_bindings` runs per-variant; for `variant="async"` the expected set is limited to domains where an `Async*` class has already been found (class-gated coverage). `_validate_no_unbound_operation_specs` stays per `OperationSpec` (the sync OperationSpec is reused by both modes — the usage counter is unified). | +| `avito/core/swagger_report.py` | The JSON report becomes variant-aware: the summary stores `sync` and `async` coverage separately, `operations[].bindings` contains a mapping by variant. The old `bound`/`unbound` fields remain sync-only compatibility until a separate report API bump. | +| `avito/auth/provider.py` | Extract shared cache state into `_cache.py`. `AuthProvider` itself stays sync. We keep `_access_token`/`_refresh_token`/`_autoteka_access_token` as `@property` shims over `TokenCache` (with setters), because `tests/core/test_authentication.py:122-127` mutates the field directly via `replace()`. | +| `avito/core/deprecation.py` | `deprecated_method(...)` becomes async-aware: if the original method is a coroutine function, the wrapper is also `async def` and does `return await method(...)`, preserving `__sdk_deprecation__`. This is needed for deprecated async doubles in `cpa` and `ads`. | +| `avito/core/transport.py` (separately) | Phase 1a: `_merge_headers` is refactored first — it takes an already-resolved bearer token, and resolution is called as a separate line above. All other shared helpers are Phase 1b. | +| `avito/__init__.py` | + export `AsyncAvitoClient`, `AsyncPaginatedList`. `AsyncPaginator` is not raised to root level, because the sync-root exports `PaginatedList` but not `Paginator`; `AsyncPaginator` remains accessible from `avito.core`. | +| `avito/core/__init__.py` | + export `AsyncTransport`, `AsyncOperationExecutor`, `AsyncOperationTransport`, `AsyncPaginatedList`, `AsyncPaginator`. | +| `avito/auth/__init__.py` | + export `AsyncAuthProvider`, `AsyncTokenClient`, `AsyncAlternateTokenClient`, if these classes are declared public for consumer-side tests and type hints. | +| `avito/testing/__init__.py` | + export `AsyncFakeTransport`, `AsyncSwaggerFakeTransport` and shared helpers, so that async test utilities are the same public contract as sync `FakeTransport`. | +| `avito//__init__.py` | At every M2/M3…M12 we add the export of the corresponding `Async` classes; without this, `_gen_reference.py`, mkdocstrings and IDE-discovery will not see the async surface. | +| `docs/site/assets/_gen_reference.py` | + extension of `public_domain_packages()` / `public_domain_classes()` / `public_domain_methods()` to pick up `async_domain.py` and `Async` classes alongside their sync counterparts. The builder must not depend solely on `avito..__all__`: it must import `avito..domain` and `avito..async_domain` directly, then preserve the order sync-class → async-class. Important: the current `write_domain_pages()` writes only `::: avito.` and does not use the class/method helper functions; M1 must move domain page generation to explicit class directives (`::: avito..ClassName`) in the order sync-class → async-class. `ensure_debug_info_exists()` is extended to `AsyncAvitoClient.debug_info()`. Without this, `make docs-strict` after M2-PoC will not prove reference completeness. | +| `avito/core/domain.py` | + `AsyncDomainObject` with async `_execute` and async `_resolve_user_id`. Sync `DomainObject` — unchanged. | +| `pyproject.toml` | + `pytest-asyncio = "^0.24"` in dev-deps. + `[tool.pytest.ini_options] asyncio_mode = "strict"` and `asyncio_default_fixture_loop_scope = "function"`. Without an explicit `asyncio_default_fixture_loop_scope`, `pytest-asyncio` 0.23+ emits a `PytestDeprecationWarning` on every test — at the time of M1 there is no `filterwarnings = error` in `pyproject.toml` (verified by grep), so this won't break pytest immediately, but it will accumulate noise in output and block enabling `filterwarnings = error` in the future. Locked in M1 PR preventively. | +| `Makefile` | + an `async-parity-lint` target, included in `quality`; `make check` after M1 must remain green. | +| `scripts/lint_architecture.py` | We do not touch `LEGACY_FILENAMES`, but public-method checks apply to `domain.py` and `async_domain.py`; the AST parser must consider `ast.AsyncFunctionDef` on equal footing with `ast.FunctionDef`. | +| `scripts/lint_docstrings.py` | Checks `avito/*/domain.py` and `avito/*/async_domain.py`, so async public methods do not get generic / reference-poor docstrings. | +| `scripts/lint_async_parity.py` | A new static linter, not pytest: checks `Async ↔ X`, signatures, return annotations (`PaginatedList[T] ↔ AsyncPaginatedList[T]`), `async def`, binding equality, and the absence of extra/missing public methods. | +| `scripts/lint_swagger_bindings.py` | No CLI changes (the logic is moved into `swagger_linter.py`). | +| `tests/contracts/test_swagger_contracts.py` | Filtered to `variant="sync"` and continues to check sync `SwaggerFakeTransport` without changing behavioral coverage. | +| `STYLEGUIDE.md` | M1 normatively allows a dual-mode SDK: `async_domain.py`, `AsyncDomainObject`, `AsyncTransport`/`httpx.AsyncClient`, async lifecycle, and variant-aware Swagger bindings. The sync-only recommendation is replaced with a description of two surfaces. | +| `docs/site/explanations/swagger-binding-subsystem.md` | A section on `variant` and class-gated coverage. | +| `docs/site/explanations/domain-architecture-v2.md` | A paragraph on `async_domain.py` as an allowed file paired with `domain.py`. | +| `README.md`, `mkdocs.yml`, `docs/site/index.md`, `docs/site/reference/client.md`, `docs/site/reference/pagination.md`, `docs/site/reference/testing.md`, `docs/site/how-to/index.md` | In M-final updated from "synchronous SDK" to dual-mode SDK and given links to async lifecycle/testing/pagination. | + +### New files (M1) + +``` +avito/core/_transport_shared.py # IO-agnostic helpers, retry/error mapping/headers + # (_merge_headers takes bearer_token: str | None; + # _request_binary_async mirrors sync _request_binary) +avito/core/_async_rate_limit.py # AsyncRateLimiter (asyncio.Lock + asyncio.sleep + # over shared RateLimitState) +avito/core/async_transport.py # AsyncTransport (httpx.AsyncClient) +avito/core/async_pagination.py # AsyncPaginatedList, AsyncPaginator, AsyncPageFetcher +avito/auth/_cache.py # TokenCache + map_token_response +avito/auth/async_provider.py # AsyncAuthProvider (separate asyncio.Lock for + # the main and autoteka tokens) +avito/auth/async_token_client.py # AsyncTokenClient, AsyncAlternateTokenClient + # (with @swagger_operation(..., variant="async")) +avito/async_client.py # AsyncAvitoClient (lifecycle + auth/debug_info/closed-state; + # domain factory methods empty in M1) +avito/testing/async_fake_transport.py # AsyncFakeTransport (httpx.MockTransport+AsyncClient) +avito/testing/async_swagger_fake_transport.py + # AsyncSwaggerFakeTransport: async contract runner + # for discovered bindings with variant="async" +tests/async_fake_transport.py # thin re-export with DeprecationWarning (as in sync; + # template copied 1:1 from tests/fake_transport.py) +tests/core/test_async_transport.py +tests/core/test_async_pagination.py +tests/core/test_async_executor.py +tests/core/test_async_client_lifecycle.py +tests/auth/test_async_provider.py +tests/contracts/test_async_swagger_contracts.py + # Swagger-spec compliance for async bindings +scripts/lint_async_parity.py # static linter, not pytest +``` + +### New files (M2-PoC + M3…M12, per domain) + +``` +avito//async_domain.py +tests/domains//test__async.py +``` + +## Contracts of new classes + +### `avito/core/async_transport.py` + +```python +class AsyncTransport: + def __init__( + self, + settings: AvitoSettings, + *, + auth_provider: AsyncAuthProvider | None = None, + client: httpx.AsyncClient | None = None, + sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, + ) -> None: ... + + async def request(self, method, path, *, context, params=None, json_body=None, + data=None, files=None, headers=None, content=None, + idempotency_key=None) -> httpx.Response: ... + async def request_json(...) -> object: ... + async def download_binary(...) -> BinaryResponse: ... # full-buffer, see below + async def aclose(self) -> None: ... + async def __aenter__(self) -> AsyncTransport: ... + async def __aexit__(self, *exc) -> None: ... + @property + def auth_provider(self) -> AsyncAuthProvider | None: ... + def debug_info(self) -> TransportDebugInfo: ... +``` + +Implements `AsyncOperationTransport` (Protocol, async mirror of `OperationTransport` from +`avito/core/operations.py`). + +`AsyncTransport.request()` internally (an exact mirror of sync `Transport.request()`, +`avito/core/transport.py:146-185`): + +1. calls `bearer_token = await self._auth_provider.get_access_token()` (if required); +2. passes `bearer_token` to the shared `_merge_headers(...)` — strictly a pure function; +3. **on every retry-loop attempt** (including the first): `delay = await + self._rate_limiter.acquire()` **before** `await self._client.request(...)` — mirrors + sync `Transport.request()` line 148. If `delay > 0` — the same info log + `transport rate limit delay` with `reason="client_rate_limit"` is written, as in sync; +4. **after a successful response**: `self._rate_limiter.observe_response(headers= + response.headers)` — mirrors sync line 183. `observe_response` remains a sync + method on `AsyncRateLimiter` (only state mutation under `asyncio.Lock` inside, + no sleep, no IO; await is not needed); +5. the loop of retry decisions delegates to the shared `_decide_*_retry`; +6. on 401 — `self._auth_provider.invalidate_token()` (sync clear-cache operation), + a repeated `await self._auth_provider.get_access_token()`, one retry; +7. catches only `httpx.TimeoutException` and `httpx.NetworkError`, like sync + `Transport` at the time of M1. `asyncio.CancelledError` and any `BaseException` + propagate outwards without retry — see the shared retry-loop contract above. + +**Forbidden** to call `self._client.request(...)` without first awaiting `await +self._rate_limiter.acquire()`: otherwise rate-limit headers (Retry-After, X-RateLimit-*) +will update the state, but the actual serialization of requests through the limiter will not work, and +parallel coroutines will go out in a batch. Locked in by the test +`tests/core/test_async_transport.py::test_request_acquires_rate_limiter_before_httpx_call`, +which via `AsyncFakeTransport` runs 5 parallel coroutines on one transport +and verifies that `RateLimitState._tokens` is updated exactly one at a time before each +httpx call (and not in a batch), and the second test +`test_request_calls_observe_response_after_success` verifies that +`observe_response` was called with the same headers the mock returned. + +**Rate limiter in async.** One rate limiter belongs to one `AsyncTransport` +(not to each call coroutine). All coroutines sharing the transport must +serialize through `asyncio.Lock` inside the limiter — otherwise N parallel requests +will independently compute "must wait X seconds" and will go out in a batch after waiting, breaking +the limit. + +**Contract of shared parts of RateLimiter.** The current `avito/core/rate_limit.py` contains +*both* the token-bucket state (`_tokens`, `_blocked_until`, `_updated_at`), *and* +`while True: self._sleep(delay)` inside `acquire()` — sleep is baked into the method. The sync +`RateLimiter` cannot be "wrapped" in async without rework, because internally there is +a `threading.Lock` that is forbidden to hold across `await`. Therefore the decomposition +is strict, in three parts: + +1. **`RateLimitState`** (pure dataclass in `avito/core/_transport_shared.py`): + `_tokens: float`, `_updated_at: float`, `_blocked_until: float`, policy + (`rate`, `capacity`, `enabled`). Methods: + - `compute_delay(now: float) -> float` — a pure function that **does not** sleep, + returns 0 if it can go immediately, otherwise the required delay. Reserves a token + if it returns 0 (mutates state). + - `observe_response(now: float, headers: Mapping[str, str]) -> None` — a pure + update of `_blocked_until` from rate-limit headers (no IO). + +2. **`RateLimiter`** (sync, stays in `avito/core/rate_limit.py`): stores + `RateLimitState` + `threading.Lock` + `_sleep` + `_clock`. To avoid changing + sync behavior, the wrapper preserves the current order: the lock is held only on + computing/mutating state, and sleep is performed outside the `threading.Lock`. Any change + to sync-concurrency semantics — a separate deliberate PR, not part of M1. + +3. **`AsyncRateLimiter`** (new, **in `avito/core/_async_rate_limit.py`** — + symmetrically with sync `avito/core/rate_limit.py`, so that grep `RateLimit` finds both + modules side by side and the async infrastructure does not bleed into `async_transport.py`). + Stores + **a separate `RateLimitState`** (not shared with sync — state is not shared between + modes; sync and async transports are independent entities with independent + buckets) + `asyncio.Lock` + `_clock` + `_sleep: Callable[[float], + Awaitable[None]] = asyncio.sleep`. `async def acquire()` is + `async with self._lock: while (delay := state.compute_delay(now())) > 0: + await self._sleep(delay)`. + +The async wrapper deliberately holds `asyncio.Lock` during the wait, so that several +coroutines sharing one transport do not wake up in a single batch after the same delay. +`asyncio.Lock` is created when `AsyncRateLimiter` is created inside the async lifecycle +(`AsyncAvitoClient.__aenter__`, `AsyncFakeTransport.as_client()` inside the test loop, +or explicit creation by the user inside the loop) and is bound to the event loop on first +`await`. It is forbidden to reuse one `AsyncRateLimiter` across event loops. + +**Locked in by tests**: `tests/core/test_rate_limit_state.py` (pure compute); +`tests/core/test_async_transport.py::test_async_rate_limiter_serializes_concurrent_acquires` +(five parallel coroutines do not go out in a batch after waiting, but serialize under +`asyncio.Lock`). + +**Connection pool and fan-out limits.** `AsyncTransport` creates `httpx.AsyncClient` +with **default** `httpx.Limits` (max_connections=100, max_keepalive_connections=20), +without overriding. This is a deliberate decision: explicit tuning of limits in M1 is a separate +behavioral axis that should not be introduced together with the async foundation. At the same time +**the convenience methods of M-final limit fan-out**: no aggregator +(`account_health`, `listing_health`, `review_summary`, `promotion_summary`) should +spawn > 6 simultaneously in-flight tasks via `asyncio.TaskGroup` (current sync +code has at most 5–6 independent branches in `account_health`). If a domain in the future +requires parallel fan-out > 6, this is introduced in a separate PR with an explicit +semaphore policy (`asyncio.Semaphore`) — but not in 2.1.0. Locked in by the M-final DoD code review +checklist and risk table. If an external `httpx.AsyncClient` is passed by the user, +its limits are the user's responsibility; the SDK does not override them and documents +this fact in the `AsyncAvitoClient.__init__` docstring. + +**Semantics of `AsyncTransport.download_binary`.** In M1 — **full-buffer**, like sync: +internally `await response.aread()` and a `BinaryResponse` is returned with the full `bytes` +content. The streaming variant (`async for chunk in response.aiter_bytes()`) is +**out of scope for M1…M-final**: no public sync method returns a chunked stream, +`scripts/lint_async_parity.py` and the async contract suite would break it, +and Async API users would not see a divergence +from sync. If a stream is needed in the future — that is a separate API +(`download_binary_stream` or an iterator), introduced in a separate minor release +after 2.1.0 with a symmetric sync analog. Locked in by the test +`tests/core/test_async_transport.py::test_download_binary_full_buffer_matches_sync`. + +### `avito/core/operations.py` (extension) + +```python +class AsyncOperationTransport(Protocol): + async def request(...) -> httpx.Response: ... # async def, not Awaitable[T] + async def request_json(...) -> object: ... + +class AsyncOperationExecutor: + def __init__(self, transport: AsyncOperationTransport) -> None: ... + async def execute[ResponseT](self, spec: OperationSpec[ResponseT], *, + path_params=None, query=None, request=None, + headers=None, idempotency_key=None, + data=None, files=None, timeout=None, + retry=None) -> ResponseT: ... +``` + +`render_path`, `_serialize_query`, `_serialize_request`, `_merge_content_type`, +`_extract_filename` are common, reused by both executors without copying. +`AsyncOperationExecutor.execute()` repeats all three branches of the sync executor: + +- `response_kind == "json"`: `payload = await transport.request_json(...)`, then + `response_model.from_payload(payload)`; +- `response_kind == "empty"`: `response = await transport.request(...)`, then + `EmptyResponse(status_code=response.status_code, headers=dict(response.headers))`; +- `response_kind == "binary"`: the executor calls a module-level helper + `_request_binary_async(transport, *, spec, path, context, params, headers, + idempotency_key)` — async mirror of sync `_request_binary` (`avito/core/operations.py:254-278`). + The helper is module-level and accepts an `AsyncOperationTransport` Protocol (not a concrete + `AsyncTransport`), as sync accepts `OperationTransport`. Inside, + `await transport.request(...)` with method/path from `OperationSpec`, then it builds + `BinaryResponse(content=response.content, content_type=..., + filename=_extract_filename(...), status_code=..., headers=dict(response.headers))`. + The helper lives in **`avito/core/operations.py`** next to sync `_request_binary` (not + in `_transport_shared.py`), because the sync version is already there and works with + the `OperationTransport` Protocol — these are two symmetric twins on the same level + of abstraction, and splitting them across different modules only multiplies navigation. + `_extract_filename` is already module-level in the same file — reused without copies. + `download_binary()` remains a low-level convenience method of `AsyncTransport`, + but is **not** part of the `AsyncOperationTransport` Protocol, otherwise the binary branch will + diverge from sync executor and lose method/path from `OperationSpec`. + +The binary branch is locked in by an M1 unit test on the executor +(`tests/core/test_async_executor.py::test_binary_branch_uses_request_binary_async_helper`, +verifies that `_request_binary_async` is actually called and `BinaryResponse` +is built from the same fields as sync) and an M12 domain test for `OrderLabel.download()` via +`AsyncSwaggerFakeTransport`/`AsyncFakeTransport`. + +**Executor retry policy — exact mirror of sync.** `AsyncOperationExecutor.execute()` +chooses retry in the same order as sync `OperationExecutor`: `retry or spec.retry`, +with the same defaulting, and propagates it to `AsyncTransport.request()` with an identical argument. +Forbidden: (1) take `retry` only from the argument and ignore `spec.retry`, (2) take +`spec.retry` always and ignore the override. Locked in by the unit test +`tests/core/test_async_executor.py::test_executor_retry_resolution_matches_sync`, +parameterized with three cases `(retry=None, spec.retry=A) → A`, +`(retry=B, spec.retry=A) → B`, `(retry=B, spec.retry=None) → B` and comparing the result with +sync `OperationExecutor` on the same `OperationSpec`. Without this test, divergence in +retry semantics between sync and async could go unnoticed. + +A note on Protocol typing: for async methods in `Protocol` we use `async def`, not +`Awaitable[T]` in the return annotation of a sync signature. This gives mypy strict correct +runtime-protocol matching and avoids double wrapping. + +### `avito/core/domain.py` (extension) + +```python +@dataclass(slots=True, frozen=True) +class AsyncDomainObject: + transport: AsyncTransport + + async def _execute[ResponseT](self, spec: OperationSpec[ResponseT], *, + path_params=..., query=..., request=..., + headers=..., idempotency_key=..., data=..., + files=..., timeout=..., retry=...) -> ResponseT: ... + async def _resolve_user_id(self, user_id: int | str | None = None) -> int: ... +``` + +Async double of sync `DomainObject._resolve_user_id`: the same fallback order as the +current sync code in `avito/core/domain.py`: first the argument, then `settings.user_id`, +then an internal raw request to `/core/v1/accounts/self` via transport. This is +a deliberate exception for a base helper: `core` does not import +`avito.accounts.operations.GET_SELF`, to avoid creating a core → domain dependency. +The Swagger binding for `/core/v1/accounts/self` is covered by the public +`Account.get_self()` / `AsyncAccount.get_self()`, while `_resolve_user_id` remains an +internal helper without a separate binding. If sync `_resolve_user_id` is moved +to the executor in the future, async changes in the same PR. + +### `avito/core/async_pagination.py` + +```python +class AsyncPaginatedList[ItemT]: + def __init__(self, fetch_page: AsyncPageFetcher[ItemT], *, + start_page: int = 1, + first_page: JsonPage[ItemT] | None = None) -> None: ... + def __aiter__(self) -> AsyncIterator[ItemT]: ... + async def materialize(self) -> list[ItemT]: ... + async def aload_until(self, index: int) -> None: ... + @property + def loaded_count(self) -> int: ... + @property + def known_total(self) -> int | None: ... + @property + def source_total(self) -> int | None: ... + @property + def is_materialized(self) -> bool: ... + +type AsyncPageFetcher[ItemT] = Callable[[int | None, str | None], + Awaitable[JsonPage[ItemT]]] + + +class AsyncPaginator[ItemT]: + def __init__(self, fetch_page: AsyncPageFetcher[ItemT]) -> None: ... + def iter_pages(self, *, start_page: int = 1) -> AsyncIterator[JsonPage[ItemT]]: ... + async def collect(self, *, start_page: int = 1) -> list[ItemT]: ... + def as_list( + self, + *, + start_page: int = 1, + first_page: JsonPage[ItemT] | None = None, + ) -> AsyncPaginatedList[ItemT]: ... +``` + +`AsyncPaginatedList` does **not** inherit `list[T]` — async iteration and list indexing +are incompatible. We document this explicitly in the docstring and in the `pagination` how-to. The +page transition semantics are identical to sync `PaginatedList._consume_page` (including `next_cursor`, +`page+per_page`, `has_next_page`). + +**Concurrency contract.** `AsyncPaginatedList` does not support concurrent iteration +of one instance from multiple coroutines. But this should not turn into silent data +corruption: the class stores an active-iteration flag (`_active_iterator`) and fail-fast +raises `RuntimeError("AsyncPaginatedList уже итерируется; используйте materialize() или создайте отдельный список.")`, +if a second `__aiter__` starts before the first finishes. If fan-out is needed — +call `await materialize()` once and iterate over the resulting `list[T]`, +or create a separate `AsyncPaginatedList` per consumer. Documented +in the class docstring and in `docs/site/explanations/pagination-semantics.md` +(addition in M-final). Locked in by the behavior of +`tests/core/test_async_pagination.py::test_concurrent_aiter_raises_runtime_error`. + +**Lifecycle contract — behavior after transport `aclose()`.** `AsyncPaginatedList` +captures the `fetch_page` callable at creation time, which holds a reference to +`AsyncTransport`. If the user calls `await client.aclose()` while an +`AsyncPaginatedList` is mid-iteration (i.e. the first page is loaded but +subsequent pages are not), the next `__anext__` / next `aload_until` / +`materialize()` must raise `ClientClosedError("Клиент закрыт во время итерации +AsyncPaginatedList; пагинация прервана.")` rather than silently returning the +partial buffer or hanging on a closed `httpx.AsyncClient`. Implementation: the +`fetch_page` wrapper checks `transport._closed` (or the client's `_closed` flag, +propagated via an internal hook) before each network call; if closed, raises +`ClientClosedError`. Already-buffered items from previous pages are **not** +flushed — the iterator simply stops on the next page boundary. The same rule +applies to `AsyncPaginator.iter_pages()` and `collect()`. Locked in by +`tests/core/test_async_pagination.py::test_aiter_raises_after_client_aclose` and +`::test_materialize_raises_after_client_aclose`. + +`AsyncPaginator` is mandatory as an implementation helper: sync domains use +`Paginator(...).as_list(...)` in 4 places (`avito/ads/domain.py:266,1183`, +`avito/accounts/domain.py:170,383`). The current public surface does not return +`Paginator` directly, so async public methods return `AsyncPaginatedList[T]`, +not `AsyncPaginator[T]`. `AsyncPaginator` itself remains accessible from `avito.core` for +core API symmetry: `iter_pages()` — `AsyncIterator`, `collect()` — coroutine, +`as_list()` creates an `AsyncPaginatedList`, passing `first_page` like its sync analog. + +### `avito/auth/_cache.py` + +```python +@dataclass(slots=True) +class TokenCache: + access_token: AccessToken | None = None + refresh_token: str | None = None + autoteka_access_token: AccessToken | None = None + def access_is_fresh(self, now: datetime) -> bool: ... + def autoteka_is_fresh(self, now: datetime) -> bool: ... + def reset_access(self) -> None: ... + def reset_autoteka(self) -> None: ... + +def map_token_response(payload: object, *, now: datetime | None = None) -> TokenResponse: ... +``` + +`AuthProvider` and `AsyncAuthProvider` store `TokenCache` and use the shared `map_token_response`. + +**Compat-shim for existing tests.** `tests/core/test_authentication.py:122-127` +directly reads and assigns `provider._access_token` via `dataclasses.replace(...)`. +To avoid touching tests in the M1 PR (scope-creep risk), `AuthProvider` keeps three +attribute shims via `@property`/setter: + +```python +@property +def _access_token(self) -> AccessToken | None: return self._cache.access_token +@_access_token.setter +def _access_token(self, value: AccessToken | None) -> None: + self._cache.access_token = value +# similarly for _refresh_token, _autoteka_access_token +``` + +The shims are marked `# legacy private accessor — see PR M1` and are removed later in a separate PR +along with test migration. + +### `avito/auth/async_provider.py` + +```python +class AsyncTokenFetcher(Protocol): + """Async mirror of sync `TokenFetcher` (avito/auth/provider.py:67-70).""" + async def __call__(self, settings: AuthSettings) -> TokenResponse: ... + + +@dataclass(slots=True) +class AsyncAuthProvider: + settings: AuthSettings + token_client: AsyncTokenClient | None = None + alternate_token_client: AsyncAlternateTokenClient | None = None + autoteka_token_client: AsyncTokenClient | None = None + token_fetcher: AsyncTokenFetcher | None = None + _cache: TokenCache = field(default_factory=TokenCache, init=False, repr=False) + _refresh_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False) + _autoteka_refresh_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False) + + async def get_access_token(self) -> str: ... # double-checked + _refresh_lock + async def refresh_access_token(self) -> TokenResponse: ... + def invalidate_token(self) -> None: ... # sync clear cache, no await + async def aclose(self) -> None: ... + async def get_autoteka_access_token(self) -> str: ... # double-checked + _autoteka_refresh_lock + def token_flow(self) -> AsyncTokenClient: ... + def alternate_token_flow(self) -> AsyncAlternateTokenClient: ... +``` + +**Contract of `invalidate_token()` — sync, no await.** The method performs one operation +`self._cache.access_token = None` (atomic assignment of a dataclass field). This +is safe outside `_refresh_lock`, because in asyncio there is no true parallelism between +coroutines of the same loop: between two `await` points control is not transferred, and +a parallel coroutine cannot "catch" half-updated state. **Forbidden** to make +`invalidate_token` a coroutine with `async with self._refresh_lock` — this introduces a false +appearance of protection, increases latency of 401-handling in `AsyncTransport.request()`, and +contradicts the sync contract, where `AuthProvider.invalidate_token()` is also sync. Locked in +by the test `tests/auth/test_async_provider.py::test_invalidate_token_is_sync_and_idempotent`, +which verifies that the method can be called outside a coroutine (e.g. from a `__del__` wrapper), +that a repeated call is a no-op, and that after it `get_access_token()` triggers a refresh. + +**Lock lifecycle.** In Python 3.10+ `asyncio.Lock()` created outside the event loop +lazily binds to the loop on first `await`. To avoid cross-loop UB: +`AsyncAuthProvider` is created inside `AsyncAvitoClient.__aenter__` (or `_from_transport`), +and is not reused across different event loops. We document this in the docstring of +`AsyncAvitoClient` and in the risk section. + +A separate `_autoteka_refresh_lock` is needed because concurrent first-touch +`get_autoteka_access_token()` would cause duplicate Autoteka OAuth requests. The sync provider +does not have this protection (the GIL doesn't help between threads), but in async this is already an explicit race. + +### `avito/auth/async_token_client.py` + +```python +@dataclass(slots=True, frozen=True) +class AsyncTokenClient: + __swagger_domain__ = "auth" + settings: AuthSettings + token_url: str | None = None + client: httpx.AsyncClient | None = None + sdk_settings: AvitoSettings | None = None + + async def aclose(self) -> None: ... + + @swagger_operation("POST", "/token", spec="Авторизация.json", + operation_id="getAccessToken", + method_args={"request": "body"}, + variant="async") + async def request_client_credentials_token(self, request) -> TokenResponse: ... + + @swagger_operation("POST", "/token", spec="Автотека.json", + operation_id="getAccessToken", + method_args={"request": "query.grant_type"}, + variant="async") + async def request_autoteka_client_credentials_token(self, request) -> TokenResponse: ... + + async def request_refresh_token(self, request) -> TokenResponse: ... # no binding (sync also has none) +``` + +`AsyncAlternateTokenClient` is a mirror of the sync analog with `variant="async"` on two methods +(`getAccessTokenAuthorizationCode`, `refreshAccessTokenAuthorizationCode`). + +Inside `AsyncTokenClient._request_token` a **separate `AsyncTransport`** is created with +`auth_provider=None` (mirror of sync `TokenClient._build_transport()`, see +`avito/auth/provider.py:345-350`). Use of the main `AsyncTransport` through +`AsyncAuthProvider` is forbidden — that would loop the OAuth request through the auth provider itself. + +`avito/core/swagger_discovery.py._NON_DOMAIN_BINDING_MODULES` is augmented strictly with +`"avito.auth.async_token_client"` (not `async_provider`) — because the classes with swagger +bindings (`AsyncTokenClient`, `AsyncAlternateTokenClient`) live there. Otherwise +async bindings of the auth domain will not enter discovery. + +### `avito/async_client.py` + +```python +class AsyncAvitoClient: + def __init__(self, settings: AvitoSettings | None = None, *, + client_id: str | None = None, + client_secret: str | None = None, + http_client: httpx.AsyncClient | None = None) -> None: ... + + @classmethod + def from_env(cls, *, env_file=...) -> AsyncAvitoClient: ... + @classmethod + def _from_transport(cls, settings, *, transport, auth_provider) -> AsyncAvitoClient: ... + + @property + def settings(self) -> AvitoSettings: ... + @property + def auth_provider(self) -> AsyncAuthProvider: ... + @property + def transport(self) -> AsyncTransport: ... + + def auth(self) -> AsyncAuthProvider: ... + def debug_info(self) -> TransportDebugInfo: ... + async def aclose(self) -> None: ... + async def __aenter__(self) -> AsyncAvitoClient: ... + async def __aexit__(self, *exc) -> None: ... + + # M2-PoC: tariff() is added as template validation + # M3+: at each step ALL domain factory methods are added at once + # def tariff(self) -> AsyncTariff: ... # M2-PoC + # def account(self, user_id=None) -> AsyncAccount: ...# M4 + # ... +``` + +**Lifecycle of `from_env` and `__init__`.** `from_env` is a **synchronous** factory +(mirror of sync `AvitoClient.from_env`): it reads `.env`/environment, constructs +`AvitoSettings`, and returns an uninitialized `AsyncAvitoClient`. SDK-managed +network resources (`httpx.AsyncClient`, `asyncio.Lock`) do not yet exist at this stage — +they are created lazily in `__aenter__` for the current event loop. Exception: if +the user explicitly passes an external `http_client`, it already exists, but transport +and auth-provider are still bound to it only in `__aenter__`. This is critical because: +- `httpx.AsyncClient` created in one loop and used in another gives + undefined behavior; +- `asyncio.Lock` binds to the loop on first `await` and does not transfer between + loops; +- `from_env` itself is not `async` — the user should not connect the SDK via + `await AsyncAvitoClient.from_env()`. + +**Usage contract — required patterns:** + +```python +# (1) Recommended: context manager +async with AsyncAvitoClient.from_env() as client: + ... + +# (2) Allowed: explicit aclose +client = AsyncAvitoClient.from_env() +async with client: # initialization in __aenter__ + ... +# or +client = AsyncAvitoClient.from_env() +await client.__aenter__() # equivalent of async with +try: + ... +finally: + await client.aclose() +``` + +**Forbidden:** +```python +client = AsyncAvitoClient.from_env() +await client.transport.request_json(...) # transport is still None — RuntimeError +``` + +`transport`/`auth_provider` are `@property`, return `RuntimeError("AsyncAvitoClient +не инициализирован: используйте 'async with' или дождитесь '__aenter__'")` until +the first `__aenter__`. Locked in by the test +`tests/core/test_async_client_lifecycle.py::test_access_before_aenter_raises`. + +**Public client-contract parity.** `AsyncAvitoClient` mirrors the public contract of +`AvitoClient` that does not depend on a specific domain: + +- `debug_info()` is available after `__aenter__`, returns the same `TransportDebugInfo` + as sync `AvitoClient.debug_info()`, and works through `_require_transport()`; +- `auth()` checks `_ensure_open()` and returns `AsyncAuthProvider`; +- `aclose()` is idempotent, sets `_closed=True`, and closes `AsyncTransport` + + `AsyncAuthProvider`; +- after `aclose()` public methods (`auth()`, `debug_info()`, factory methods, + convenience methods after M-final) raise `ClientClosedError("Клиент закрыт; создайте новый AsyncAvitoClient.")`; +- access to `transport`/`auth_provider` before `__aenter__` remains an initialization + error, and after `aclose()` — a closed-client error. If both states are + possible, `_closed` has priority. + +This is not optional sugar: `debug_info()` is part of the public diagnostic contract of the sync SDK +and must appear in M1, before the first domain. + +**Ownership of an external `httpx.AsyncClient`.** In M1 we cannot quietly change the current +sync semantics. Currently, sync `Transport.close()` closes the `httpx.Client` even if +it was passed externally. Therefore `AsyncTransport.aclose()` in 2.1.0 mirrors this +behavior: it closes the internal `httpx.AsyncClient` regardless of whether it was created by +the SDK or passed by the user. This is locked in by a test, so the plan does not rely on a +wrong assumption about `_owns_client`. If an "external client is +owned by caller" policy is needed, it is introduced in a separate PR simultaneously for sync and async with an explicit +CHANGELOG/deprecation design. If `http_client` is passed, its loop must match +the loop in which `__aenter__` will be called; cross-loop ownership is UB, +verified only by documentation. + +**Rollback on partial failure in `__aenter__`.** If `__aenter__` raises in +the middle (for example, `httpx.AsyncClient` is already created, but `AsyncAuthProvider.__post_init__` +or lazy lock initialization throws an exception), all already-created state must +be closed before re-raising. Implementation: + +```python +async def __aenter__(self) -> AsyncAvitoClient: + try: + # any initialization that may raise + await self._transport.__aenter__() + return self + except BaseException: + await self.aclose() # idempotent: safe on partially-initialized state + raise +``` + +`aclose()` is idempotent and resilient to closing partially-initialized state +(each sub-resource checks `is None` before `await x.aclose()`). Locked in by +the test `tests/core/test_async_client_lifecycle.py::test_aenter_rollback_on_partial_failure`. + +In M1 `AsyncAvitoClient` has no domain factory methods — only lifecycle, `auth()`, +`debug_info()`, closed-state, and a smoke-call via raw `transport.request_json(...)` +in a test. **Convenience methods `account_health`, +`business_summary`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`, +`promotion_summary`, `capabilities`** on `AsyncAvitoClient` are a separate (last) +stage, M-final, because some of them combine multiple domains and are not needed before +all domains are ported. + +**Classification of M-final methods (important for implementation).** Not all 8 methods are +aggregators; the pattern must not be conflated. + +| Method | Type | Sync behavior | Async behavior | +|---|---|---|---| +| `account_health` | aggregator with dependencies | first `_resolve_user_id`; then independent branches `balance`, `listing_health`, `chat_summary`, `order_summary`, `review_summary`; `promotion_summary` depends on `item_ids` from `listing_health` (`avito/client.py:206-263`) | **`asyncio.TaskGroup`** only for independent branches after `user_id`; `promotion_summary` runs after `listing_health`. Errors of `balance`/`listing_health` propagate as in sync; chat/order/review/promotion remain safe sections via `_safe_summary_async`. | +| `listing_health` | aggregator with first-list dependency | first `ad.list(...)`, then if `item_ids` are present, calls item stats, calls stats and spendings (`avito/client.py:265-368`) | the list of ads is loaded first; after obtaining `item_ids`, **`asyncio.TaskGroup`** for independent stats/calls/spendings. Spendings remains an optional safe section; stats/calls errors propagate as in sync. | +| `business_summary` | **alias** for `account_health` | `return self.account_health(...)` (`avito/client.py:184-204`) | `return await self.account_health(...)` — **no `TaskGroup`**, 1:1 delegation | +| `chat_summary` | leaf/sequential | `_resolve_user_id`, then a single call to the `messenger` domain | sequential `async def`; no `TaskGroup` needed | +| `order_summary` | leaf | a single call to the `orders` domain | one `await`; `TaskGroup` forbidden | +| `review_summary` | mixed required+optional | `review().list()` is optional-safe, `rating_profile().get()` is required (`avito/client.py:396-429`) | **sequentially**, without `TaskGroup`: first `reviews` via `_safe_summary_async` (optional, error → unavailable section), then `await rating_profile().get()` (required, error propagates). TaskGroup forbidden, see "Important TaskGroup subtlety" block below. | +| `promotion_summary` | conditional aggregator | `list_orders`; if `item_ids` are passed — additionally `list_services` (`avito/client.py:431-465`) | without `item_ids` one `await`; with `item_ids` **`asyncio.TaskGroup`** is allowed for `list_orders` and `list_services`. | +| `capabilities` | static reference | does not make network probe requests, only builds `CapabilityDiscoveryResult` from current configuration (`avito/client.py:467-531`) | remains a sync-shaped CPU-only method without `TaskGroup` and without network calls. If capabilities later becomes a probe method, that is a separate API/behavior change with tests. | + +The rule: we parallelize only actually independent network branches and preserve sync +error semantics. Aliases (`business_summary`), CPU-only methods (`capabilities`), and +leaves (`order_summary`) do not get `TaskGroup`. This is recorded in the M-final DoD below +as an explicit code review checklist check. + +**Important TaskGroup subtlety for mixed required+optional branches.** In sync code, +`review_summary` first does `review().list()` via `_safe_summary` (optional, error +turns into an unavailable section), then `rating_profile().get()` (required, error +propagates). If in async we put both tasks into **one** `TaskGroup` and the required +`rating` raises — TaskGroup will cancel the not-yet-finished optional `reviews` task via +`CancelledError`. This **changes sync semantics**: in sync, `reviews` could already have +completed successfully by the time of the `rating` error. So the correct async pattern for +mixed branches is **sequential within branch, parallel across required-only**: + +```python +async def review_summary(self, ...) -> ReviewSummary: + # reviews — optional, always wrapped in _safe_summary_async + reviews_result, reviews_unavailable = await _safe_summary_async( + "reviews", lambda: self.review(...).list(...).materialize() + ) + # rating — required, propagates AvitoError + rating = await self.rating_profile().get() + return ReviewSummary(reviews=reviews_result, rating=rating, + unavailable_sections=reviews_unavailable) +``` + +`asyncio.TaskGroup` in `review_summary` is allowed **only** if both branches go through +`_safe_summary_async` (i.e. both are optional) — that changes the public contract and is **forbidden** +in M-final. Allowed parallelism: if both were required and independent. The current +optional+required mix excludes TaskGroup parallelism for `review_summary`. +The M-final DoD checks: `review_summary` async does not use TaskGroup, runs +sequentially reviews-then-rating. The same rule applies to any future +aggregator with a mixed required/optional set of branches. + +**Cancellation-safe pattern for aggregators (mandatory).** Used: +`asyncio.TaskGroup` (Python 3.11+, our floor is 3.12+) with per-section try/except +converting `AvitoError → SummaryUnavailableSection` (like sync `_safe_summary`, +`avito/client.py:91-98`). `asyncio.gather(..., return_exceptions=True)` is forbidden, +because it returns `CancelledError` as an ordinary result — that swallows +cancellation semantics. Template: + +```python +async def _safe_summary_async[T]( + section: str, factory: Callable[[], Awaitable[T]], +) -> tuple[T | None, list[SummaryUnavailableSection]]: + try: + return await factory(), [] + except asyncio.CancelledError: + raise # cancellation propagates, never swallowed + except AvitoError as error: + return None, [_summary_unavailable_section(section, error)] + +async def account_health(self, ...) -> AccountHealthSummary: + async with asyncio.TaskGroup() as tg: + t_balance = tg.create_task(self.account(resolved_user_id).get_balance()) + t_listings = tg.create_task(self.listing_health(...)) + t_chat = tg.create_task(_safe_summary_async("chat", lambda: ...)) + ... + # After exiting TaskGroup all tasks are completed or cancelled atomically. + # The dependent promotion branch starts after item_ids from listings are obtained. +``` + +On cancellation of the outer call, `TaskGroup` will cancel all child tasks and raise +`CancelledError` — without hanging coroutines and without partial state. + +### `avito/testing/async_fake_transport.py` + +```python +class AsyncFakeTransport: + def __init__(self, *, base_url: str = "https://api.avito.ru") -> None: ... + def add(self, method, path, *responses) -> AsyncFakeTransport: ... + def add_json(self, method, path, payload, *, status_code=200, headers=None) -> AsyncFakeTransport: ... + def build(self, *, retry_policy=None, user_id=None, + authenticated: bool = False, + auth_settings: AuthSettings | None = None) -> AsyncTransport: ... + def as_client(self, *, user_id=None, retry_policy=None, + authenticated: bool = False, + auth_settings: AuthSettings | None = None) -> AsyncAvitoClient: ... + def count(self, *, method=None, path=None) -> int: ... + def last(self, *, method=None, path=None) -> RecordedRequest: ... + requests: list[RecordedRequest] +``` + +Mirror of sync `FakeTransport` (`avito/testing/fake_transport.py`). Uses +`httpx.MockTransport(self._handle)` over `httpx.AsyncClient`. `RecordedRequest`, +`JsonValue`, `json_response`, `route_sequence` — reused without copies from sync. +`sleep` is `lambda _: asyncio.sleep(0)`. + +**Auth mode for fake transport.** By default `authenticated=False`, so simple +domain tests, like sync `FakeTransport.as_client()`, do not require a `/token` route. +For M1 auth/retry smoke and contract tests, where it is needed to verify a real +`Authorization`, 401 invalidate, and token refresh, `authenticated=True` is used: + +- `as_client(authenticated=True)` creates `AsyncAuthProvider` with `AsyncTokenClient` / + `AsyncAlternateTokenClient` built on the same `httpx.MockTransport(self._handle)`; +- the main `AsyncTransport` receives this `auth_provider`, so the first + authorized request triggers `/token`, and a 401 clears the cache and triggers a second + `/token`; +- the test must explicitly register token routes via `add_json("POST", "/token", ...)`; +- `build(authenticated=True)` returns a low-level `AsyncTransport` with the same + auth provider, so core tests do not bypass the auth pipeline. + +Without this, the M1 smoke could look "authenticated" but actually go through +a transport with `auth_provider=None` and not verify refresh semantics. + +**Semantics of `user_id` separately from `authenticated`.** `as_client(user_id=N, +authenticated=False)` is the correct pattern for domain tests that call +methods with `_resolve_user_id` (for example, `AsyncAccount.get_balance()`). In this +mode: + +- `AsyncAvitoClient.settings.user_id == N` — `_resolve_user_id` takes it as a + fallback and **does not** make a raw request to `/core/v1/accounts/self`; +- `AsyncTransport` is created with `auth_provider=None` — the request-level header + `Authorization` is not set; `RequestContext.requires_auth=True` without an auth + provider does not fail (mirror of sync `Transport._merge_headers`: `if + context.requires_auth and self._auth_provider is not None: ...`); +- if a domain test requires both `user_id` and a check of the auth pipeline (refresh, 401 + invalidate) — combine `as_client(user_id=N, authenticated=True)`, but in this case + any request to `/core/v1/accounts/self` is still not made, because + `user_id` is already resolved. + +This is a mirror of the sync `FakeTransport.as_client(user_id=N)` contract (without +`authenticated`). Locked in by the test +`tests/core/test_async_fake_transport.py::test_as_client_user_id_skips_self_lookup`. + +**Concurrency policy.** `_handle` mutates `self.requests.append(...)` and `route.pop(0)` +for `route_sequence` scenarios. For tests with `asyncio.gather(...)` (primarily +M-final convenience methods) `_handle` takes `self._handle_lock = asyncio.Lock()` and +serializes match-and-record under it. Without this, two parallel coroutines may +simultaneously call `route.pop(0)` and get an unpredictable order of responses. + +**Lock initialization in `__init__` (not lazy).** It is not allowed to lazily create `asyncio.Lock` +from `_handle`: two coroutines simultaneously passing `if self._handle_lock is +None` would create different lock objects — and serialization will break before the first `await`. +Therefore `self._handle_lock = asyncio.Lock()` is created in `__init__`; the +`AsyncFakeTransport` instance is created inside an async test/loop, and the lock is bound to the loop +on the first `await`. The cost: `AsyncFakeTransport` cannot be reused across event +loops (under `pytest-asyncio strict` this does not happen anyway — each test gets +its own loop). Documented in the docstring: "AsyncFakeTransport is safe for concurrent +access within a single event loop; create a new instance in each test; do not +reuse across loops." + +## Swagger binding — change details + +1. `SwaggerOperationBinding` (`avito/core/swagger.py`): + - `variant: Literal["sync","async"] = "sync"` (frozen field). + - The decorator `swagger_operation(..., variant: Literal["sync","async"] = "sync")`. + - `__post_init__` validates the runtime value: any value other than `"sync"` / + `"async"` gives `ConfigurationError`, because `Literal` does not protect a call + from runtime code. + - Double-decorating one function remains `ConfigurationError`. + +2. `DiscoveredSwaggerBinding` (`avito/core/swagger_discovery.py`): + - `variant: Literal["sync","async"]` is copied from `SwaggerOperationBinding`. + - `_iter_domain_modules` looks for both modules in each package: `.domain` and `.async_domain`. If `async_domain` is not there — we ignore (this is a normal stage of migration). + - `canonical_map` remains a sync-only compatibility property, so that current + `tests/contracts/test_swagger_contracts.py` and the report builder do not get a + silent semantic break. The implementation explicitly filters `variant == "sync"`, not + "last binding wins". + - new API: `canonical_map_by_variant: Mapping[Literal["sync","async"], + Mapping[str, DiscoveredSwaggerBinding]]` and/or `binding_for(operation_key, + variant)`. The internal unique key is `(operation_key, variant)`. + +3. `swagger_linter.py`: + - `_validate_single_binding_per_sdk_method` — unchanged: the key `binding.sdk_method` is unique even in async (because `module.class.method` differs). + - `_validate_duplicate_bindings` — key `(operation_key, variant)` instead of `operation_key`. It is allowed to have two independent chains (sync + async) for one swagger operation. + - `_validate_factory` becomes variant-aware with **class-gated coverage**, symmetrically to + `_validate_complete_bindings`: + - sync binding with a given `factory` checks the factory on `AvitoClient`. + - async binding with a given `factory` is checked on `AsyncAvitoClient` **only if** + the corresponding `Async` already exists in the domain (the same class-gated predicate + as in `_validate_complete_bindings`). If `Async` has not yet appeared — async + bindings for its class must not exist at all (per-class invariant), and if there are + exceptions — it is not checked. + - an async binding **without** a `factory` in the decorator (primarily auth bindings + `AsyncTokenClient.request_client_credentials_token`, + `AsyncAlternateTokenClient.*`) is skipped exactly as sync without `factory`. + So in M1 (when there are no domain factories on `AsyncAvitoClient` yet), async auth + bindings do not fail on `_validate_factory`, and starting from M2-PoC `tariff()` the factory must + appear. + Without this class-gated approach, either M1 is red (false fail on auth), or the invariant + is weakened (green swagger-lint with a missing async factory in M3+). The M1 DoD explicitly + includes a check that `_validate_factory(variant="async")` is green for async auth + bindings and does not require any domain factory on `AsyncAvitoClient`. + - `_validate_complete_bindings(operations, bindings)` → `_validate_complete_bindings(operations, bindings, variant)`. Runs twice: + - for `variant="sync"`: expected set = all `operations` (as it is now). + - for `variant="async"`: expected set = **per-class**, not per-domain. + For each sync class in the domain (``) we check: does + `Async` exist (by name, `cls.__name__.startswith("Async") and + cls.__name__.removeprefix("Async") == sync_cls.__name__`, in the same package). + If yes — all swagger operations bound to sync methods of this class + must have an async double in `Async`. If not — the class is considered + "not yet ported", and its operations do not enter expected for + `variant="async"` at this stage. + + In addition to `_API_DOMAINS`, for `domain == "auth"` we take operations from + `Авторизация.json` and `Автотека.json` if `AsyncTokenClient` / + `AsyncAlternateTokenClient` is found respectively (the same per-class logic). + + This gives two important properties: + 1. The M1 foundation is mergeable: for API domains there is no `Async` → + domain expected = ∅; for auth, expected only includes + `AsyncTokenClient` / `AsyncAlternateTokenClient` bindings. Linter is green. + 2. A large domain (e.g. M11 `ads` with 3 classes `Ad`/`AutoloadProfile`/ + `AutoloadReport`) can theoretically be split into sub-PRs by class; + the M3…M12 DoD still requires closing the domain to 100%, but per-class + granularity provides a safe exit point if the PR balloons. + (Splitting is allowed only on an explicit decision, not as "I'll do the rest + later" — see DoD M3…M12.) + - `_validate_operation_spec_coverage` — unchanged (sync OperationSpec is the single source of truth for both modes; reusing the spec between sync and async methods is not forbidden). `used_specs` is `set[id(spec)]`, so the same `OperationSpec` from sync and async bindings is not duplicated and not lost. + - `_operation_specs_for_sdk_method` (`avito/core/swagger_linter.py:578`) resolves the spec via `unwrapped_method.__globals__`. Async methods must import the spec explicitly (`from avito..operations import LIST_SPEC`), otherwise the resolution will return `()` and the spec will be considered unbound. A pre-flight test verifies this works; if it does not — a fallback plan for Phase 1b is laid out **before** the start of M1, not "as we go": + 1. **Primary fallback** (minimum changes): extend `_operation_specs_for_sdk_method` + so that in addition to `__globals__` it also goes through `inspect.getsourcefile(method)` → + `ast.parse` → looks in the source for **local** references to `OperationSpec` objects + and resolves them via AST + module `getattr`. This covers the case where a spec + is invoked through `self._execute(LIST_SPEC, ...)` without `from ... import LIST_SPEC` + at module level. + 2. **Secondary fallback** (structural): introduce a class-level attribute + `__operation_specs__: Mapping[str, OperationSpec]` on each domain class, + listing `(method_name, spec)` pairs. `_operation_specs_for_sdk_method` + reads the attribute first, before `__globals__`. This option requires writing + sync classes the same way (for symmetry), but provides deterministic resolution without AST. + The decision between primary and secondary is taken **by pre-flight result**, no later, + with a scope estimate in hours. If neither works — this is a blocker for M1, and the plan + is rolled back for review (a foundation without a working swagger-coverage gate + is not fit for purpose). + - `_validate_json_body_model_coverage` runs against sync bindings; async + bindings are checked through the `AsyncSwaggerFakeTransport` contract suite, so as + not to duplicate schema-lint errors on shared `OperationSpec`s. + +4. `swagger_report.py` and the docs report: + - `operations[].binding` remains a sync-only compatibility field. + - `operations[].bindings_by_variant = {"sync": ..., "async": ...}` is added. + - `summary.bound/unbound/duplicate/ambiguous` remain sync-only until a separate + report API bump. + - `summary.variants.sync` and `summary.variants.async` are added with the same + counters. For M1 the async domain summary may be `bound=0, expected=0`, + while the async auth summary must already cover its bindings; after M-final, total + async expected/bound = 204. + - `docs/site/assets/_gen_reference.py` and `reference/operations.md` show both + SDK links when an async binding already exists, but do not break the current sync map. + +5. Contract tests: + - `tests/contracts/test_swagger_contracts.py` filters bindings by + `variant="sync"` and preserves the current exhaustive sync behavior. + - new `tests/contracts/test_async_swagger_contracts.py` — a Swagger-spec + compliance test, not an architecture/introspection test: for each discovered + binding with `variant="async"`, `AsyncSwaggerFakeTransport` builds + `AsyncAvitoClient`, calls the async SDK method via `await`, validates + the actual request against Swagger, and checks success/error payload mapping. + In M1 it covers async auth bindings; in M2+ it automatically extends to + ported domains. + +6. `scripts/lint_async_parity.py` — a static linter, checks for each Async class: + - the name `Async` ↔ a sync `` exists in the same package; + - class-level metadata mirrors the sync class: `__swagger_domain__`, + `__sdk_factory__`, `__sdk_factory_args__` must match by value + (except for deliberately documented legacy wrappers, if such appear in a separate PR); + - the set of public async methods (`async def` without `_` prefix) matches sync methods; + - method enumeration is filtered by `func.__qualname__.startswith(cls.__name__ + ".")`, + so as not to count methods inherited from `AsyncDomainObject` (`_execute`, `_resolve_user_id`) + or `object`; + - for each pair `(sync_method, async_method)`: + - `inspect.signature(sync).parameters` (without `self`) == `inspect.signature(async).parameters`; + - the return annotation either matches, or `PaginatedList[T]` ↔ `AsyncPaginatedList[T]`, + or `BinaryResponse`/wrapper-model matches directly; `Paginator[T] ↔ + AsyncPaginator[T]` is allowed only if a public sync method that actually returns + `Paginator[T]` appears in the future; + - both are decorated with `@swagger_operation` for the same `(spec, method, path, operation_id)`, differing only by `variant`. + - for each async class-level `__sdk_factory__` it checks that such a factory + exists on `AsyncAvitoClient`, has a signature compatible with the sync factory + on `AvitoClient`, and returns the corresponding `Async`. + If metadata is missing, it is a blocker even if decorators are present: + swagger discovery, the reference builder, and IDE-discovery must see the async class + the same way as the sync class. + This linter is invoked from `make quality`; pytest does not contain parity/introspection + tests, because the STYLEGUIDE only allows functional tests and + Swagger-spec compliance tests in pytest. + + The linter additionally exports `iter_async_classes() -> Iterator[type[AsyncDomainObject]]` + as a public module API (without `_` prefix). This is the **single source of truth** + for the list of `Async` classes: the M-final verification script takes it from there instead of + hardcoding names, so adding a new class does not require editing the M-final check. + Contract of `iter_async_classes()`: + - returns all `Async` classes from all `avito//async_domain.py` + (excluding `EXCLUDED_PACKAGES = {"auth", "core", "testing"}` — auth bindings + do not get a reference); + - order: stable sort by `(package_name, class_name)`; + - does not depend on prior state (can be called before and after any M stage). + +## Stages + +### Pre-flight for PR M1 + +Before opening PR M1 (all of this is done locally and validated before commit): + +- [x] `grep -rn "\._access_token\|\._refresh_token\|\._autoteka_access_token" tests/` — + record all private probes; ensure that the compat-shim in `AuthProvider` + covers each. Currently found case: `tests/core/test_authentication.py:122-127`. +- [x] `grep -rn "\bPaginator\b" avito/` — record all 4 usage sites + (`avito/ads/domain.py:266,1183`, `avito/accounts/domain.py:170,383`). + All current usage sites end with `.as_list(...)`; there is no direct public + return of `Paginator`. `AsyncPaginator.as_list()` is needed by M4 + (`accounts`), but a root-level export of `AsyncPaginator` is not needed. +- [x] `grep -rn "len(.*Paginated\|\\b[a-z_]*list\\[[0-9-]" avito/ tests/` — find all + consumers of the list API on `PaginatedList[T]` (indexing, `len`, `bool`, slice). + `AsyncPaginatedList` deliberately does NOT replicate the list API: each such case must + either be safe (sync-only), or explicitly replaced with `await materialize()` / + `loaded_count` in the async double. The list is recorded in the PoC commit message. +- [x] `grep -rn "^async def test_" tests/` — ensure that existing tests have no + async functions without `@pytest.mark.asyncio`. After enabling + `asyncio_mode = "strict"`, any such test will start being ignored (warning, + not failure). If found — add the marker in a pre-flight commit, separately from M1. +- [x] Confirm the minimum supported Python version in `pyproject.toml`. The SDK already + uses PEP 695 (`type PageFetcher[ItemT] = ...` in `avito/core/pagination.py:10`), + which means Python **3.12+** is required. All async contracts (`type AsyncPageFetcher`, + `async def execute[ResponseT]`) keep this same floor; raising it is unnecessary, but + explicitly recorded in the M1 PR description. +- [x] Baseline run on a clean `main` — save **nodeids of existing tests** and + their pass/fail statuses: + `poetry run pytest --collect-only -q tests/core tests/auth tests/domains tests/contracts | grep '::' > /tmp/baseline_nodeids.txt` + and then `poetry run pytest -q --tb=no $(cat /tmp/baseline_nodeids.txt) > + /tmp/baseline_main.txt`. Used in the M1 DoD; new async tests after M1 + do not enter the baseline comparison. +- [x] Verify that `_operation_specs_for_sdk_method` (`avito/core/swagger_linter.py:578`) + works with `async_domain.py`: a test stub with `async def m(self): return self._execute(SOME_SPEC)` + and `from ...operations import SOME_SPEC` — the function must find `SOME_SPEC` via + `unwrapped_method.__globals__`. If it does not work — extend the function (Phase 1b), + otherwise leave unchanged. +- [x] Read `docs/site/assets/_gen_reference.py` in full and record + existing filter points: `PACKAGE_ROOT.glob("*/domain.py")`, + `EXCLUDED_PACKAGES`, `public_domain_classes()` (filter by `DomainObject` inheritance + and `value.__module__.startswith(f"avito.{package}.")`), `public_domain_methods()` + (filter by `value.__qualname__.startswith(f"{domain_class.__name__}.")`), + and `write_domain_pages()` (currently writes one `::: avito.` and does + not use class helpers). The builder extension in M1 must reuse + this logic for `async_domain.py` + `AsyncDomainObject` descendants, and + `write_domain_pages()` must move to explicit class directives sync → async + and not rely solely on `avito..__all__`. Without this, the reference will be + asymmetric. +- [x] Read `scripts/lint_architecture.py` and `scripts/lint_docstrings.py`: + current checks look only at `domain.py` and `ast.FunctionDef`. M1 must + extend them to `async_domain.py` and `ast.AsyncFunctionDef`. +- [x] Read `avito/core/deprecation.py`: the current `deprecated_method` returns a + sync wrapper. M1 must add an async-aware wrapper before porting the + deprecated methods of `cpa`/`ads`. +- [x] `grep -rn "@deprecated_method\|deprecated_method(" avito/cpa/ avito/ads/` — + record the **exact** number of sync deprecated methods that require async doubles. + At the time of writing the plan: 3 in `avito/cpa/domain.py:491,541,585` and 4 in + `avito/ads/domain.py:1416,1457,1523,1558` — totaling 7. The async-aware wrapper in + `deprecation.py` is a mandatory artifact of M1, without which M6 (`cpa`) and M11 (`ads`) + cannot close. If the actual number diverges from the recorded one — update + the sequencing table and DoD M6/M11 before the start of M1. +- [x] Read `avito/core/swagger_linter.py::_validate_factory` in full and record + current behavior: which fields of the binding it gates on (`factory`, `factory_args`), + how it resolves the factory on `AvitoClient`, what it considers an error. M1 must extend + it with class-gated coverage (see Swagger section). Without full understanding of the current + logic, the extension risks weakening the invariant for sync bindings. +- [x] **Run pre-flight locally, record results in a tracked artifact**: + a new file `docs/dev/preflight-async-m1.md` is created and committed in + a separate pre-flight commit (before opening M1) capturing **all** of the + following in machine-readable form: + (1) the actual list of `_access_token`/`_refresh_token`/`_autoteka_access_token` + probes in `tests/` (paths + line numbers); + (2) the actual `Paginator` usage sites in `avito/` (4 expected, paths + + line numbers); + (3) the actual `len(...)` / `[idx]` / `bool(...)` / slice usages on + `PaginatedList[T]` across `avito/` and `tests/`; + (4) the actual count and locations of `@deprecated_method` in + `avito/cpa/` and `avito/ads/` (7 expected, with line numbers); + (5) the existing `^async def test_` lines (expected: empty); + (6) the result (pass/fail) of the `_operation_specs_for_sdk_method` + smoke test on an async stub, and the chosen fallback (none / primary / + secondary) with a one-paragraph justification; + (7) the concrete diff baseline: `/tmp/baseline_nodeids.txt` and + `/tmp/baseline_main.txt` are produced and their sha256 sums are + recorded in the artifact (the actual files are not committed — + only the hashes, for later reproducibility); + (8) the Python interpreter version, Poetry lockfile hash, and `httpx` + version in use at pre-flight time. + Without `docs/dev/preflight-async-m1.md` in the M1 PR diff, the PR is + not opened. The artifact is referenced from the M1 PR description and + is not deleted by M-final (it remains permanent provenance for the + async migration). + +### M1 — Foundation (1 PR) + +DoD: + +- [x] `make check` green: test, typecheck (mypy strict), lint (ruff), + swagger-lint --strict, architecture-lint, async-parity-lint, + docstring-lint, build. +- [x] `make docs-strict` green: M1 edits `STYLEGUIDE.md`, + `swagger-binding-subsystem.md` and `domain-architecture-v2.md` + extends + `_gen_reference.py` (see the table "Existing, modified in M1"). Without editing + `STYLEGUIDE.md`, the plan formally contradicts the normative sync-only text. + Without a green docs-strict, we cannot guarantee that the reference builder in M2-PoC + will see the first `Async`. If at M1 there is not a single `Async` yet — the builder + is verified to be neutral (sync reference is generated identically to baseline). +- [x] Test coverage of the foundation is no lower than the sync analogs (sample check via `coverage report`). +- [x] Smoke test: `AsyncAvitoClient` via `AsyncFakeTransport.as_client(authenticated=True)` + (without respx) makes one authorized request; `/token` is actually called + via `AsyncTokenClient`; after 401 the cache is cleared and `/token` is called + again; retry on 429 fires; `Authorization` and `Idempotency-Key` + are propagated; `aclose()` correctly closes `httpx.AsyncClient` and + `AsyncAuthProvider`. +- [x] Ownership test: `AsyncTransport.aclose()` closes the passed + `httpx.AsyncClient`, because that is the chosen mirror policy of the current sync + `Transport.close()`. The test separately covers idempotent double-close. +- [x] The async auth public surface mirrors sync: `AsyncAvitoClient.auth()` returns + `AsyncAuthProvider`, and `token_flow()` / `alternate_token_flow()` return + async token clients with `variant="async"` bindings. +- [x] Async client diagnostic/closed contract mirrors sync: `debug_info()` returns + `TransportDebugInfo` after `__aenter__`; `auth()` and `debug_info()` fail before + initialization with an understandable `RuntimeError`; after `aclose()` they and future factory + methods fail with `ClientClosedError`; repeated `aclose()` is a no-op. +- [x] The documentation `swagger-binding-subsystem.md` reflects variant and class-gated coverage. +- [x] `AsyncSwaggerFakeTransport` is added and exported from `avito.testing`; the async + contract suite is green for discovered async bindings (`auth` in M1, domains + appear later). +- [x] Public sync surface is unchanged — formal: pass/fail statuses + **only of baseline nodeids from `/tmp/baseline_nodeids.txt`** are identical to + the baseline test from `main` (see pre-flight). New async tests do not participate + in the comparison. Any divergence on old nodeids = blocker. +- [x] Phase 1a (`_merge_headers` refactor) is split out as a separate commit inside the PR — for bisect-friendly history. +- [x] **`pyproject.toml` contains `asyncio_default_fixture_loop_scope = "function"`** in `[tool.pytest.ini_options]` next to `asyncio_mode = "strict"`. At the time of M1 `filterwarnings = error` is not configured in the project, so the absence of this option will not break pytest immediately, but `pytest-asyncio` 0.23+ will start emitting `PytestDeprecationWarning` on every async test — this accumulates in output and blocks future enabling of `filterwarnings = error`. We enable it preventively. +- [x] **`_validate_factory(variant="async")` is green for async auth bindings without a single domain factory on `AsyncAvitoClient`**. The class-gated predicate: factory-check is not run on an async binding whose class does not yet have `Async` in the domain, and skips bindings without `factory` in the decorator. Locked in by the unit test `tests/core/test_swagger_linter.py::test_validate_factory_async_skips_unported_classes`. +- [x] **The resolver `_operation_specs_for_sdk_method` for `async_domain.py`**: the pre-flight smoke test is green (resolution via `__globals__` works with `from ...operations import SOME_SPEC`). If pre-flight is red — in this same M1 PR, the primary fallback (AST resolution from the source file) **or** the secondary fallback (class-level `__operation_specs__`) is applied. Any fallback is locked in `swagger_linter.py` with the test `tests/core/test_swagger_linter.py::test_resolve_specs_from_async_domain`. +- [x] **`AsyncOperationExecutor` retry resolution mirrors sync**: the test `tests/core/test_async_executor.py::test_executor_retry_resolution_matches_sync` is parameterized with the `(retry, spec.retry)` triple and compares the result with sync `OperationExecutor`. +- [x] **`AsyncAuthProvider.invalidate_token` is sync and idempotent**: the test `tests/auth/test_async_provider.py::test_invalidate_token_is_sync_and_idempotent` is green. +- [x] **`httpx.AsyncClient` is created with default limits** (without override). A test forbidding SDK-side tuning of limits is not needed in M1; the M-final DoD has a fan-out ≤ 6 check. +- [x] **`AsyncTransport.request()` calls `await self._rate_limiter.acquire()` before each httpx call and `observe_response()` after a successful response** — exact mirror of sync `Transport.request()` (lines 148, 183). Locked in by two tests: `tests/core/test_async_transport.py::test_request_acquires_rate_limiter_before_httpx_call` (5 parallel coroutines on one transport — tokens are spent one at a time, not in a batch) and `::test_request_calls_observe_response_after_success` (post-condition). +- [x] **`_request_binary_async` module-level helper in `avito/core/operations.py`** is an async mirror of sync `_request_binary`. Accepts `AsyncOperationTransport` Protocol, returns `BinaryResponse` with the same fields. Closed-test: `tests/core/test_async_executor.py::test_binary_branch_uses_request_binary_async_helper`. +- [x] **End-to-end binary-branch coverage in M1 (synthetic, before any domain port)**: + to prove the full async pipeline works for `response_kind == "binary"` + **before** M12 `orders` lights it up via `OrderLabel.download()`, M1 adds + one synthetic binding inside the test suite (not in production code) — + a `_TestBinaryDomain` with an `async def download(...)` method decorated + with `@swagger_operation(..., variant="async")` over a fake + `OperationSpec` with `response_kind == "binary"`. Test + `tests/core/test_async_executor.py::test_async_executor_full_binary_pipeline` + drives the spec end-to-end through `AsyncSwaggerFakeTransport` → + `AsyncOperationExecutor` → `_request_binary_async` → + `BinaryResponse`, and asserts that `content`, `content_type`, `filename`, + `status_code`, `headers` match the response body byte-for-byte. Without + this, M1 ships an executor whose binary branch is verified only at the + unit level (`test_binary_branch_uses_request_binary_async_helper`) — + regressions across executor + transport + fake-transport interaction + would only be caught in M12, weeks later. The synthetic binding lives + in `tests/_fixtures/synthetic_binary_domain.py` and is excluded from + `swagger_discovery._iter_domain_modules` (its module path does not + start with `avito.`). +- [x] **`AsyncRateLimiter` lives in `avito/core/_async_rate_limit.py`** (not inside `async_transport.py`). Symmetric to sync `avito/core/rate_limit.py`. +- [x] **`scripts/lint_async_parity.py` exports `iter_async_classes()` as a public API** — used by the M-final verification script and any external tool that needs the canonical list of `Async` classes. +- [x] CHANGELOG `## [Unreleased]` in the root `CHANGELOG.md` is updated with: + `- Фундамент Async API: AsyncTransport, + AsyncAuthProvider, AsyncOperationExecutor, AsyncPaginatedList, + AsyncAvitoClient (без factory-методов доменов); RateLimitState вынесен в shared`. + +### M2-PoC — Proof-of-concept of the template (a separate PR, before reworking domains) + +**The goal of this step is to validate the template on a minimal domain and at the same time close +`tariffs` completely.** This is not a "partial domain PR": at merge time `tariffs` must +have an async surface, tests, swagger coverage, and reference 1:1. The PoC may return +feedback like "the `AsyncPaginator` contract needs to be extended", "discovery does not see +the spec", "mypy strict complains about return covariance" — and that is a normal expected +outcome. All contract changes are made in **the same PR**, and if the changes require +rework of the M1 foundation, the PoC is rolled back, the foundation is reworked in a separate +PR, after which the PoC is reopened. M3 does not start until M2-PoC is green and +`tariffs` is closed at 100%. + +The PoC takes `tariffs` (1 sync operation with binding) — minimal surface without +pagination, without autoteka-flow, without write methods. That is enough to poke +all foundation layers in one end-to-end scenario. + +DoD M2-PoC: +- [x] `avito/tariffs/async_domain.py` is created, `AsyncTariff` mirrors `Tariff` + exactly on 1 public method. +- [x] `AsyncTariff` contains class-level metadata mirroring `Tariff`: + `__swagger_domain__ = "tariffs"`, `__sdk_factory__ = "tariff"`, + `__sdk_factory_args__ = {"tariff_id": "path.tariff_id"}`. +- [x] `avito/tariffs/__init__.py` exports `AsyncTariff` next to `Tariff`. +- [x] `AsyncAvitoClient.tariff()` factory method returns `AsyncTariff`. +- [x] `tests/domains/tariffs/test_tariffs_async.py` contains an async double of the sync + golden-path scenario and additional async-risk scenarios: 401, 429, + transport error. All tests are green. +- [x] `make check` is green, including `swagger-lint --strict` (for `tariffs` async-coverage + 1:1 is now required). +- [x] `scripts/lint_async_parity.py` is green. +- [x] `tests/contracts/test_async_swagger_contracts.py` is green for async auth + + `tariffs`. +- [x] The generated reference docs `docs/site/reference/domains/tariffs.md` + contain an async section. +- [x] **`_gen_reference.py` is validated on a real domain**: after the builder extension in M1, on M2-PoC it sees `AsyncTariff` for the first time and must generate a reference page with both classes (`Tariff` + `AsyncTariff`). `make docs-strict` is green, in the generated `site/reference/domains/tariffs/` or `site/reference/domains/tariffs.html` both sections are present. If the builder requires polish — it is included in the same PR (this is what the PoC is for). Specifically in `_gen_reference.py`: `public_domain_packages()` additionally returns the package if `*/async_domain.py` exists; `public_domain_classes()` imports `avito..domain` and `avito..async_domain` directly, not just `avito..__all__`; `Async` is filtered through `cls.__name__.startswith("Async")` + `issubclass(AsyncDomainObject)`; `write_domain_pages()` writes explicit mkdocstrings directives for each class in the order `Tariff` → `AsyncTariff`, not one shared `::: avito.tariffs`; `EXCLUDED_PACKAGES` remains the same; for `auth` (excluded) async classes do not get a reference. +- [x] **Lessons learned are recorded** in `docs/site/explanations/async-domain-template.md` + (a new file): the `async_domain.py` file template, a domain port checklist, + pitfalls discovered. This document becomes normative for M3+. +- [x] If in the course of the PoC contract changes are needed (`AsyncPaginator`/`AsyncFakeTransport`/ + `swagger_linter`/`AsyncAuthProvider`), they are **made in the same PR** or split out + into a separate M1.5-PR, but **before** the start of M3. +- [x] The root `CHANGELOG.md` (`## [Unreleased]`) is updated with: + `- Async-поддержка домена tariffs: AsyncTariff (PoC шаблона)`. + +### M3…M12 + M-final — Closing domains (one PR per domain) + +**Sequencing constraints** — what blocks what (after a green M2-PoC): + +| Stage | Must come after | Reason | +|---|---|---| +| M3 `ratings` | M2-PoC | basic template without specifics; serves as the second sanity check of the foundation | +| M4 `accounts` | M2-PoC, M3 | first domain with `AsyncPaginatedList` — validates pagination before M11 | +| M5 `realty` | M2-PoC | no pagination; parallel with M3/M6/M7/M8/M9 | +| M6 `cpa` | M2-PoC + async-aware `deprecated_method` already merged in M1 | 3 deprecated methods in `cpa/domain.py` | +| M7 `messenger` | M2-PoC | no pagination; parallel with M3/M5/M6/M8/M9 | +| M8 `jobs` | M2-PoC | webhook methods (REST), no pagination; parallel | +| M9 `promotion` | M2-PoC | no pagination; parallel | +| M10 `autoteka` | M2-PoC | autoteka token flow — independent part of auth | +| M11 `ads` | **M4 (`accounts`)** + async-aware `deprecated_method` from M1 | the complex `Ad.list` first-page reuse is tested after the simple `AsyncPaginatedList`; 4 deprecated methods in `ads/domain.py` | +| M12 `orders` | M2-PoC | independent; idempotency is critical, but is not blocked by another domain | +| M-final | **all M3…M12 + M10** | `AsyncAvitoClient.account_health` aggregates all domains; `_safe_summary_async` is symmetric to sync `_safe_summary`; M10 is mandatory for the autoteka concurrent first-touch test (see the M3…M12 table below) | + +**Parallelism**: after M2-PoC you can open M3, M5, M6, M7, M8, M9, M10, M12 in +any order (including in parallel). M4 is a mandatory gate before M11. M-final is +last. The cumulative parity invariant (see DoD M3…M12) guarantees that the merge +order of parallel PRs does not matter: each merge leaves the linter green +for all already ported domains. + +The order in the table below (increasing complexity; the simplest went into the PoC): + +| # | Domain | Sync methods with binding | Specifics | +|---|---|---|---| +| M3 | `ratings` | 4 | no pagination | +| M4 | `accounts` | 8 | first `AsyncPaginatedList` (`get_operations_history`, `list_items_by_employee`); async `_resolve_account_user_id` | +| M5 | `realty` | 7 | no pagination | +| M6 | `cpa` | 14 | no pagination | +| M7 | `messenger` | 18 | no pagination | +| M8 | `jobs` | 25 | webhook methods (REST) | +| M9 | `promotion` | 24 | no pagination | +| M10 | `autoteka` | 26 | uses autoteka token flow → end-to-end check of `AsyncAuthProvider.get_autoteka_access_token` + `_autoteka_refresh_lock` under load: **20 concurrent coroutines** in `asyncio.gather(...)` start the first `get_autoteka_access_token()`; the counter of the mocked `/token` route after `await gather(...)` must be **exactly 1**. Locked in by the test `tests/auth/test_async_provider.py::test_autoteka_concurrent_first_touch_single_token_request`. | +| M11 | `ads` | 28 | second and third `AsyncPaginatedList` (`Ad.list`, `AutoloadReport.list`); complex offset/limit first-page reuse in `Ad.list` (`avito/ads/domain.py:266`) | +| M12 | `orders` | 45 | the largest; idempotency is critical | +| M-final | — | — | convenience methods of `AsyncAvitoClient`: `account_health`, `listing_health`, and `promotion_summary` (when `item_ids` is given) use `asyncio.TaskGroup` only where all branches are **required-only** and actually independent; `review_summary` remains sequential reviews-then-rating (mixed required+optional, see the "Important TaskGroup subtlety" block); `business_summary` delegates to `account_health`; `chat_summary`/`order_summary` remain sequential leaves; `capabilities` remains CPU-only without network probe requests. `asyncio.gather(return_exceptions=True)` is forbidden. Aggregator fan-out ≤ 6 in-flight tasks. Final hardening; `docs/site/how-to/async.md`; CHANGELOG `## [Unreleased]` → `## [2.1.0]` (a roundup of accumulated entries from M1…M12 + a record of convenience methods). | + +Contents of each M3…M12: + +1. `avito//async_domain.py` with `Async(AsyncDomainObject)` for **every** + sync `` in the domain. Imports the same `OperationSpec` from + `avito//operations.py` **explicitly by name** + (`from avito..operations import LIST_SPEC, GET_SPEC, ...`) — otherwise + `_operation_specs_for_sdk_method` will not be able to resolve the spec via `__globals__` + and swagger-lint will emit `SWAGGER_OPERATION_SPEC_MISSING`. +2. **Every** `Async` contains class-level metadata mirroring the sync class: + `__swagger_domain__`, `__sdk_factory__`, `__sdk_factory_args__`. The metadata is not + considered "duplication" of the Swagger contract: this is SDK discovery/factory metadata + without which the async class may not enter discovery/reference or may receive + a green decorator with a missing factory. +3. **Every** public method is decorated with `@swagger_operation(..., variant="async")` + with the same arguments `(method, path, spec, operation_id, factory, factory_args, + method_args, deprecated, legacy)` as sync. +4. `avito//__init__.py` exports **all** `Async` of the domain next to + sync classes, so that mkdocstrings, the IDE, and the generated reference see the public + async surface. +5. Registration of **all** `Async` of the domain in `AsyncAvitoClient` (factory methods by + names identical to sync). +6. `tests/domains//test__async.py` is a mirror of + `tests/domains//test_.py` via `AsyncFakeTransport`. Tests are + marked with `@pytest.mark.asyncio`. **Every** sync test has an async double + with the same scenario. +7. If the domain has pagination — the corresponding methods return + `AsyncPaginatedList[T]` (mirroring sync `PaginatedList[T]`). M4 `accounts` is + the first domain with `AsyncPaginatedList`; M11 `ads` validates the complex first-page + reuse in `Ad.list`. +8. The generated reference `docs/site/reference/domains/.md` is augmented with + an async section (or a second column). +9. If the domain has write methods with `dry_run` — the async double implements the same + contract: when `dry_run=True` the transport is **not called** (the test verifies + `count(method=..., path=...) == 0`). +10. If the domain has idempotency-key behavior — async tests explicitly verify + propagation of the `Idempotency-Key` header. + +### Definition of done for each M3…M12 — close the domain at 100%, no work left over + +"100%" is defined verifiably. All items below are **mandatory**, not "nice to have": + +- [ ] **Method coverage 1:1**: for each public sync method of the domain there is an + async double; `scripts/lint_async_parity.py` is green for the domain. + Local check: `python -c "from avito..domain import *; from + avito..async_domain import *"` + `scripts/lint_async_parity.py` + without allowlist/skip for the current domain. +- [ ] **Test coverage scenario-by-scenario**: every scenario from + `tests/domains//test_.py` has an async double with the same + business meaning. Additional async tests are allowed and required where + they cover async-specific risks (401 refresh via async auth, + cancellation, concurrent pagination/fake transport, async rate limiter). + The test counts do not have to be equal; the async count must be **no less** + than sync count, and the PR description contains a short mapping table + `sync test -> async test`. Covered: golden path, 401, + 403, 422, 429, transport error/timeout, pagination (if any), idempotency + (for write), `dry_run` (if there is one in sync). +- [ ] **Swagger-lint coverage 1:1 for the domain**: `swagger-lint --strict` after the stage + requires an async binding for **every** swagger operation of this domain; class-gated + coverage gating is enabled, and the domain is no longer "empty by async". No + exceptions/skips for individual methods. +- [ ] **Async Swagger contract coverage**: `tests/contracts/test_async_swagger_contracts.py` + calls **every** async binding of the domain via `AsyncSwaggerFakeTransport` and + validates the request/response/error contract. This is a mandatory Swagger-spec + compliance test, so it is allowed by the STYLEGUIDE. +- [ ] **Documentation**: the generated `docs/site/reference/domains/.md` contains an async section for + **all** ported classes; `make docs-strict` is green; links and code + examples compile. +- [ ] **No TODOs/FIXMEs/`pytest.skip`/`xfail` in added files**: + `git diff main..HEAD -- avito// tests/domains// | grep -E + "TODO|FIXME|@pytest.mark.skip|xfail"` is empty. Any deferral of work = blocker. +- [ ] **Error messages in Russian only** (STYLEGUIDE.md, "Errors" section): + all new `raise ("...")` in `async_domain.py` are written in Russian, + without English inclusions. Code review checklist; `make lint` does not catch this directly, + but mixed languages are a formal blocker. If the sync analog already + uses English (legacy) — leave it as is in sync, and in async + write in Russian and open a separate issue for sync migration. +- [ ] **`make check` is green locally and in CI**. +- [ ] **AsyncAvitoClient is fully configured for the domain**: factory methods return + ready objects, lifecycle (`aclose`/`__aexit__`) correctly closes all + domain resources. +- [ ] **Sync regression = 0**: the list of pass/fail of sync tests is identical to the previous + stage (sanity check via comparing `pytest -q --tb=no` before and after). +- [ ] **Cumulative parity invariant**: after the merge `scripts/lint_async_parity.py` + and `tests/contracts/test_async_swagger_contracts.py` are green for **all** already + ported domains (including the current one). The stage cannot weaken the invariant + for previous domains. +- [ ] **No work "later"**: reopening a PR with the phrase "I'll finish it in the next PR" + is forbidden. If scope does not close — the PR is split or expanded, but + no partial domain is left in main. +- [ ] **Per-class split escape hatch (M11/M12 only, by explicit decision)**: for + `M11 ads` (3 classes: `Ad`/`AutoloadProfile`/`AutoloadReport`, 28 ops) and + `M12 orders` (45 ops, the largest domain) the «no partial domain» rule is + **softened by exception**: it is allowed to split the domain into a sequence of + per-class PRs (`M11a Ad`, `M11b AutoloadProfile`, `M11c AutoloadReport`; + `M12a–M12N` partitioned by `OperationSpec` group), provided that **each + sub-PR is itself class-complete**: every method of the included class has + an async double, swagger-lint per-class is 1:1, async-parity-lint is green + for the included class. Class-gated coverage in `swagger_linter.py` + already supports this (see Swagger section). Constraints: + (1) the split must be declared in the M11/M12 design comment **before** the + first sub-PR is opened, with the full list of sub-PRs and their order; + (2) the cumulative parity invariant still applies — each sub-PR leaves + `make swagger-lint --strict` green for all already ported classes; + (3) the `M11`/`M12` row in the sequencing table is replaced with the + sub-PR list, and `M-final` waits for the **last** sub-PR. + For all other domains (M3…M10) the «no partial domain» rule is hard: + one PR closes one whole domain at 100%. The exception exists strictly to + keep code-review tractable on `ads` and `orders`; it must not be invoked + retroactively to «rescue» a stuck PR on other domains. +- [ ] **CHANGELOG is updated via per-PR fragments**: each M3…M12 PR adds **one + file** under `CHANGELOG.d/-async-.md` with the content: + ```markdown + ### Added + - Async-поддержка домена : Async, Async (#) + ``` + The root `CHANGELOG.md` is **not** edited per-PR. M-final aggregates all + `CHANGELOG.d/*.md` fragments into one `## [2.1.0] - YYYY-MM-DD` section, + then deletes the fragments. Rationale: 12 parallel PRs editing a single + `## [Unreleased]` block are guaranteed to merge-conflict on every rebase; + separate fragment files have no shared lines and merge cleanly. + Implementation: + (1) M1 PR creates `CHANGELOG.d/.gitkeep` and `CHANGELOG.d/README.md` + describing the format; + (2) `make check` (via a new `scripts/check_changelog_fragments.py`) + verifies each fragment matches the schema (one `### Added`/`### Changed`/ + `### Fixed` block, no `## [...]` headings, valid markdown); + (3) M-final concatenates fragments in PR-number order, prepends + `## [2.1.0] - YYYY-MM-DD`, appends to `CHANGELOG.md`, and `git rm + CHANGELOG.d/*.md` (keeping `.gitkeep` and `README.md`). + M1 itself does **not** use a fragment — its CHANGELOG line («Фундамент + Async API») is added directly to `## [Unreleased]` of the root file + (single PR, no conflict risk), and M-final moves it into `## [2.1.0]` + together with the fragment aggregate. + +### Definition of done for M-final — release 2.1.0 + +"Final hardening" is defined verifiably: + +- [ ] **Convenience methods are implemented per the classification table** (aggregator / alias / leaf / CPU-only). Code review verifies: `asyncio.TaskGroup` is placed only in branches with actually independent network calls (`account_health`, `listing_health`, `review_summary`, `promotion_summary` when `item_ids` is given); in `business_summary` — `return await self.account_health(...)` without `TaskGroup`; `chat_summary` and `order_summary` are sequential; `capabilities` does not make network probe requests and does not use `TaskGroup`. Any violation = blocker. +- [ ] **Fan-out ≤ 6 is enforced by a real test, not just code review**: `tests/test_async_client_aggregators.py::test_account_health_fanout_does_not_exceed_six` + drives `AsyncAvitoClient.account_health(...)` through `AsyncFakeTransport` + with an instrumented `_handle` that records the **maximum number of + simultaneously in-flight requests** observed during the call (counter + incremented at the start of `_handle`, decremented after the response is + returned, peak captured under `_handle_lock`). The assertion is + `assert peak <= 6`. The same instrumentation is applied to + `listing_health`, `review_summary` (peak ≤ 1 — sequential), + `promotion_summary(item_ids=[...])` (peak ≤ 2), and + `business_summary` (delegates to `account_health`, peak ≤ 6). A single + shared `FanoutPeakRecorder` helper in `avito/testing/async_fake_transport.py` + provides the counter; aggregator tests opt in via + `AsyncFakeTransport(fanout_recorder=recorder)`. This locks the contract + against future drift: if a domain in the future adds a new branch and + pushes peak past 6, the test fails before the PR is merged. +- [ ] **`_safe_summary_async` lives in the same module as sync `_safe_summary`** — `avito/client.py` (extraction into a shared `avito/summary/_helpers.py` is allowed, but requires simultaneous moving of sync `_safe_summary`; partial extraction is forbidden, so as not to split symmetric helpers across different files). The import in `avito/async_client.py` is explicit (`from avito.client import _safe_summary, _safe_summary_async`). Circularity does not arise: `avito/client.py` does not import `avito/async_client.py`, so the import graph remains acyclic; verified by the command `python -c "import avito.async_client"` without errors and `python -c "import avito.client"` without errors. +- [ ] **The package version is bumped to 2.1.0**: `poetry version 2.1.0`, the change in `pyproject.toml` is recorded in the M-final PR. CHANGELOG `## [Unreleased]` → `## [2.1.0] - YYYY-MM-DD`, the accumulated lines M1…M12 + the entry about convenience methods and `AsyncAvitoClient` aggregators are aggregated into one section. `git tag v2.1.0` is set after merging M-final. +- [ ] **`AsyncSwaggerFakeTransport` contract suite is complete**: `tests/contracts/test_async_swagger_contracts.py` + calls all async bindings (204 Swagger operations, including auth bindings) + and checks success/error/request-body schema, like the sync contract suite. +- [ ] **`docs/site/how-to/async.md` is written**: lifecycle contract (`async with` is mandatory), an example with `AsyncFakeTransport`, a migration guide "how to rewrite a sync call to async", limitations (`AsyncPaginatedList` not list-API, full-buffer download, no streaming). Links from `docs/site/index.md` and `docs/site/how-to/index.md`. **Mandatory dedicated section "Использование под ASGI (FastAPI / aiohttp / Starlette)"** with concrete recipes: + (1) **FastAPI lifespan pattern** — `AsyncAvitoClient` is created and + `__aenter__`'d inside `@asynccontextmanager async def lifespan(app)`, + stored on `app.state.avito`, and `aclose()`'d on shutdown. The client + lives one event loop = the app's main loop; FastAPI dependencies access + it via `Depends(lambda req: req.app.state.avito)`. Code example + ≥ 15 lines, runnable. + (2) **aiohttp `cleanup_ctx`** — analog with `aiohttp.web.AppKey` and + `app.cleanup_ctx.append(avito_client_ctx)`. + (3) **Per-worker isolation under Gunicorn/Uvicorn** — one + `AsyncAvitoClient` per worker process (each worker has its own loop); + forbidden to share across processes via fork-after-init. + (4) **Forbidden pattern** — calling `AsyncAvitoClient.from_env()` at + module import time and `__aenter__`'ing it in a request handler: this + attaches `httpx.AsyncClient` to whichever loop touched it first, and any + subsequent loop change (test client, background scheduler) gives + cross-loop UB. Section explicitly shows the broken pattern with a `# ❌` + comment and explains the failure mode. + (5) **Background tasks (`asyncio.create_task`, `BackgroundTasks`)** — + same loop as the request → safe to reuse the app-level client; a + separate process-pool worker → not safe, must build its own client. +- [ ] **README/site wording is updated**: `README.md`, `mkdocs.yml`, `docs/site/index.md`, + `docs/site/reference/client.md`, `docs/site/reference/pagination.md`, + `docs/site/reference/testing.md` no longer call the SDK only synchronous. +- [ ] **`make check` + `make docs-strict` are green**; `scripts/lint_async_parity.py` + and `tests/contracts/test_async_swagger_contracts.py` are green for all 11 API domains + + auth bindings. +- [ ] **Cumulative coverage**: after M-final swagger-lint --strict requires a mutual 1:1 (sync + async) for all 204 operations. Any miss = blocker; no "we'll finish in 2.1.1". +- [ ] **CHANGELOG release-ready**: the 2.1.0 entry contains: the Async API foundation, one line per ported domain (aggregated from `## [Unreleased]` entries of M1…M12), `AsyncAvitoClient` convenience methods. 2.1.0 release notes are assembled mechanically — that is the discipline check of M3…M12. + +## Verification (how to check that the plan worked) + +### M1 +```bash +poetry install +make test # sync + new async unit tests +make typecheck # mypy strict — all Awaitable[T], AsyncPaginatedList[T] are correct +make lint # ruff +make swagger-lint # sync 1:1; async auth 1:1, domain expected is empty +make async-parity-lint # static Async ↔ X checks, not pytest +make check # final gate +poetry run pytest tests/core/test_async_transport.py tests/core/test_async_pagination.py \ + tests/core/test_async_executor.py tests/core/test_async_client_lifecycle.py \ + tests/auth/test_async_provider.py tests/contracts/test_async_swagger_contracts.py +``` + +Manual smoke (M1, in a test — not on production; via `AsyncFakeTransport`, without `respx`): +```python +import asyncio +from avito.testing.async_fake_transport import AsyncFakeTransport +from avito.core.types import RequestContext + +async def main(): + async with ( + AsyncFakeTransport() + .add_json("POST", "/token", {"access_token": "old", "expires_in": 3600}) + .add_json("POST", "/token", {"access_token": "new", "expires_in": 3600}) + .add_json("GET", "/core/v1/accounts/self", {"error": "expired"}, status_code=401) + .add_json("GET", "/core/v1/accounts/self", {"id": 1}) + .as_client(authenticated=True) + ) as client: + payload = await client.transport.request_json( + "GET", "/core/v1/accounts/self", + context=RequestContext("smoke"), + ) + assert payload == {"id": 1} + assert client.transport.debug_info().requires_auth is True + +asyncio.run(main()) +``` + +`AsyncFakeTransport` is built on `httpx.MockTransport(self._handle)` over +`httpx.AsyncClient` — that is already a self-sufficient interception mechanism; `respx` on top of it +is redundant. `respx` is worth using only if a smoke needs a unique matcher +that `add_json`/`add` does not cover (none such at the current stage). + +### M2-PoC (proof-of-concept) +```bash +poetry run pytest tests/domains/tariffs/ # sync + async for tariffs +make async-parity-lint # parity for tariffs as a static lint +poetry run pytest tests/contracts/test_async_swagger_contracts.py +make swagger-lint # async-coverage 1:1 for tariffs +make check +# Artifact: docs/site/explanations/async-domain-template.md is created +``` + +### Each M3…M12 (closing the domain at 100%) +```bash +# Sync regression baseline (sanity) +poetry run pytest -q --tb=no tests/domains//test_.py > /tmp/sync_before.txt + +# After applying changes: +poetry run pytest tests/domains// # sync + async +poetry run pytest -q --tb=no tests/domains//test_.py > /tmp/sync_after.txt +diff /tmp/sync_before.txt /tmp/sync_after.txt # must be empty + +make async-parity-lint # parity for all closed domains +poetry run pytest tests/contracts/test_async_swagger_contracts.py +make swagger-lint # async-coverage 1:1 for this domain + +# Dirty traces — empty output +git diff main..HEAD -- avito// tests/domains// \ + | grep -E "TODO|FIXME|@pytest.mark.skip|xfail" || echo "OK: no leftover work" + +# Cumulative counters (async tests no fewer than sync; scenario mapping in the PR description) +sync_count=$(poetry run pytest --collect-only -q tests/domains//test_.py | grep -c "::test_") +async_count=$(poetry run pytest --collect-only -q tests/domains//test__async.py | grep -c "::test_") +test "$async_count" -ge "$sync_count" && echo "OK: async $async_count >= sync $sync_count" + +make check +make docs-strict +``` + +### M-final +```bash +make check +make docs-strict +poetry run pytest # full set + +# Version and release notes +poetry version 2.1.0 # bump to 2.1.0 +grep -E "^## \[2\.1\.0\]" CHANGELOG.md # the 2.1.0 section exists +grep -E "^## \[Unreleased\]" CHANGELOG.md # Unreleased is empty or contains only the heading + +# CHANGELOG.d/ fragments are aggregated and removed (only .gitkeep + README.md remain) +ls CHANGELOG.d/ | grep -vE "^(\.gitkeep|README\.md)$" \ + && echo "FAIL: leftover changelog fragments" || echo "OK: fragments aggregated" + +# Fan-out ≤ 6 enforced for all aggregator convenience methods +poetry run pytest tests/test_async_client_aggregators.py -k "fanout" + +# After build, the reference contains both surfaces in each domain. +# We get the list of Async classes dynamically from the parity linter (the same source +# of truth used in make async-parity-lint), and do not hardcode — otherwise +# any addition/rename of a class requires manual editing of the script. +poetry run mkdocs build --strict 2>&1 | tee /tmp/mkdocs.log +poetry run python -c " +from scripts.lint_async_parity import iter_async_classes +for cls in iter_async_classes(): + print(cls.__name__) +" > /tmp/async_class_names.txt +while IFS= read -r cls; do + grep -R -q "$cls" site/reference/domains || echo "MISSING async section: $cls" +done < /tmp/async_class_names.txt + +# After merge +git tag v2.1.0 +git push --tags +``` + +After M-final: +- swagger-lint --strict requires mutual 1:1 coverage (sync + async) for all 11 API domains and + auth bindings; +- `scripts/lint_async_parity.py` and `tests/contracts/test_async_swagger_contracts.py` + are green for all domains; +- `pyproject.toml` version = 2.1.0; the root `CHANGELOG.md` contains `## [2.1.0]` with an aggregated + history of M1…M12 + convenience methods; +- `docs/site/reference/domains//` for each domain shows both class + surfaces (sync + async); +- 2.1.0 release with CHANGELOG: "dual-mode SDK, AsyncAvitoClient". + +## Risks and mitigations + +| Risk | Mitigation | +|---|---| +| Divergence of retry/auth logic between sync and async | All non-IO logic lives in `_transport_shared.py` and `_cache.py`; both wrappers delegate. | +| `RateLimiter` is not applicable to async (sleep + `threading.Lock` baked into `acquire()`) | Decomposition into three parts: pure `RateLimitState.compute_delay()` in shared (no sleep, no lock), sync `RateLimiter` on top (`threading.Lock` + `time.sleep`), separate `AsyncRateLimiter` (`asyncio.Lock` + `await asyncio.sleep`). State is **not** shared between modes — sync and async transports are independent. | +| `_resolve_user_id` in async diverges from the sync fallback order | The async double repeats the current sync helper: argument → `settings.user_id` → raw `/core/v1/accounts/self` via transport. The public Swagger binding for `/core/v1/accounts/self` is covered by `AsyncAccount.get_self()`, not the internal helper. | +| `download_binary` in async may implicitly become streaming, diverging from sync | M1 fixes the full-buffer semantics (`await response.aread()`), like sync. Streaming is a separate API after 2.1.0 with a symmetric sync analog. Locked in by the test `test_download_binary_full_buffer_matches_sync`. | +| An M-final convenience method is implemented as "sync with a wrapped await" (loss of parallelism) OR a leaf/CPU-only method is wrapped in an unnecessary `TaskGroup` | The M-final DoD verifies the classification by actual sync code: `TaskGroup` only for independent network branches (`account_health`, `listing_health`, `review_summary`, `promotion_summary` when `item_ids`); `business_summary` is an alias; `chat_summary`/`order_summary` are sequential; `capabilities` is CPU-only without network probes. | +| Class-gated swagger-coverage applied per-domain → a large domain (`ads`) cannot be split, or a mini-domain with two classes requires finishing before the merge | Class-gated is applied **per-class**: `Async` exists ↔ all operations of class `` must have an async binding. The absence of `Async` in the same domain does not block merging class `Async`. The M3…M12 DoD still requires closing the domain at 100%. | +| `from_env` initializes loop-dependent resources outside the loop → cross-loop UB | `from_env` is sync, SDK-managed resources (`httpx.AsyncClient`, `asyncio.Lock`) are created in `__aenter__`. If an external `http_client` is passed by the user, the transport binds to it only in `__aenter__`. Access to `transport`/`auth_provider` before `__aenter__` raises `RuntimeError` with an understandable message. Locked in by the test `test_access_before_aenter_raises`. | +| `AsyncAvitoClient` implements only domain factories and forgets the public diagnostic/closed contract of the sync client | M1 includes `auth()`, `debug_info()`, `_ensure_open()`, `_require_transport()`, `ClientClosedError` after `aclose()`, and a check of `AsyncAvitoClient.debug_info()` in `_gen_reference.py.ensure_debug_info_exists()`. | +| 2.1.0 release notes cannot be assembled mechanically because PR M3…M12 have no CHANGELOG entries | The M3…M12 DoD requires a `## [Unreleased]` line in the root `CHANGELOG.md` per PR. M-final aggregates the accumulated content into `## [2.1.0]`. | +| `_merge_headers` covertly does sync IO (`get_access_token()`) | Phase 1a as the first step refactors the contract: the helper takes an already-resolved `bearer_token: str | None`. Without this, the shared layer is not IO-agnostic, and the vary logic spreads. | +| `AsyncPaginatedList` does not inherit `list` → service expectations break | We document in the docstring; `scripts/lint_async_parity.py` allows `PaginatedList[T]` ↔ `AsyncPaginatedList[T]`. The list API is not deliberately replicated. | +| `AsyncPaginator` does not cover the helper usage `Paginator(...).as_list(...)` | The contract of `AsyncPaginator` is symmetric to sync (`iter_pages`/`collect`/`as_list`); all 4 current usage sites are covered through methods that return `AsyncPaginatedList[T]`. | +| Auth bindings do not enter async coverage | `_NON_DOMAIN_BINDING_MODULES` is augmented strictly with `"avito.auth.async_token_client"`; class-gated coverage is gated on the presence of `AsyncTokenClient`/`AsyncAlternateTokenClient`. | +| `Async` has decorators but no class-level `__sdk_factory__` / `__swagger_domain__` → discovery/reference/factory checks are incomplete | The DoD M2…M12 requires mirror class metadata for each `Async`, and `scripts/lint_async_parity.py` compares sync/async metadata and fails on absence. | +| Double-decoration of one function | The current `__swagger_binding__` protection remains; sync and async are different functions. | +| Race on the main refresh token in async | `asyncio.Lock` (`_refresh_lock`) in `AsyncAuthProvider` + double-checked pattern (like sync, but via `await`). | +| Race on the autoteka token in async | A separate `_autoteka_refresh_lock` + double-checked in `get_autoteka_access_token()`. The sync provider remains without a new thread-safety contract in M1, so as not to change sync semantics; async gets explicit protection, because concurrent first-touch through one event loop is a regular scenario. | +| `asyncio.Lock` created outside an event loop → cross-loop UB | `AsyncAuthProvider` is created inside `AsyncAvitoClient` (via `__aenter__` or `_from_transport`); the docstring explicitly warns "do not reuse across event loops". Python 3.10+ lazily binds the lock to the loop on first `await`. | +| Migration of `_access_token` to `TokenCache` breaks `tests/core/test_authentication.py:122-127` | `AuthProvider` keeps `@property`/setter shims for all three private fields; the shim is marked with a legacy comment and is removed in a separate PR. | +| `_operation_specs_for_sdk_method` does not find a spec from `async_domain.py` | Pre-flight smoke test with an async method + explicit spec import; the current implementation via `unwrapped_method.__globals__` (`swagger_linter.py:578-601`) must work, because `from ...operations import SOME_SPEC` puts the spec into the module's `__globals__`. If it does not work — fix in Phase 1b. | +| Convenience methods (`account_health`, …) lose the main user-value of async (parallelism) or change error semantics | M-final requires `asyncio.TaskGroup` only for independent subqueries and preserves sync error semantics: required branches propagate `AvitoError`, optional branches go through `_safe_summary_async`. It is forbidden to implement "sync wrapped in await" and forbidden to turn a required error into an unavailable section. | +| `asyncio.gather(return_exceptions=True)` swallows `CancelledError` in convenience methods | Forbidden; `asyncio.TaskGroup` is used (Python 3.11+, our floor is 3.12+). On cancellation of an outer call, TaskGroup atomically cancels all child tasks without losing cancellation. | +| The retry loop catches `asyncio.CancelledError` and loops cancellation | Shared `_decide_*_retry` and the `Transport`/`AsyncTransport` wrappers catch only retryable `httpx.TimeoutException` / `httpx.NetworkError`, not `BaseException` and not all of `httpx.RequestError`. Locked in by the test `test_cancelled_error_is_not_retried`. | +| `AsyncAvitoClient.__aenter__` leaves partially-initialized state on error | `__aenter__` is wrapped in `try/except BaseException`: on any exception it calls the idempotent `aclose()` and re-raises. Locked in by the test `test_aenter_rollback_on_partial_failure`. | +| Ownership of an external `httpx.AsyncClient` is not defined — potential resource leak or double-close | M1 explicitly chooses to mirror the current sync behavior: `AsyncTransport.aclose()` closes the passed `httpx.AsyncClient`. This is locked in by a test. An alternative `_owns_client` policy is only possible in a separate PR for sync and async simultaneously. | +| `AsyncFakeTransport` desynchronizes on `asyncio.gather` | `_handle_lock = asyncio.Lock()` serializes match-and-record; **created in `__init__`**, not lazily (lazy creation is a race on lock initialization itself). Locked in by the test `test_async_fake_transport_concurrent_handle`. | +| The M1 smoke goes through `AsyncFakeTransport` without an auth provider and does not verify OAuth/401 refresh | `AsyncFakeTransport.as_client(authenticated=True)` and `build(authenticated=True)` create `AsyncAuthProvider` + async token clients on the same `MockTransport`; the smoke must verify real `/token` calls, `Authorization`, invalidate after 401, and a repeated token fetch. | +| Existing `async def test_*` in the repository are silently skipped after `asyncio_mode = "strict"` | Pre-flight `grep -rn "^async def test_" tests/` records all such tests before M1; the marker `@pytest.mark.asyncio` is added in a separate pre-flight commit. | +| `len(PaginatedList)` / `paginated[0]` in code break when trying to migrate to `AsyncPaginatedList` | Pre-flight `grep` records all list-API usages. `AsyncPaginatedList` deliberately does not replicate the list API; each case is replaced with `await materialize()` / `loaded_count` in the async double or remains sync-only. | +| Hidden work "later" in domain PRs (TODO/FIXME/skip) | The DoD M3…M12 explicitly requires empty output of `grep -E "TODO|FIXME|@pytest.mark.skip|xfail"` over the diff; async tests must be no fewer than sync tests, and the PR description contains a mapping `sync test -> async test`; the PR is not merged with partial coverage of the domain. | +| The PoC discovers that the foundation (M1) is insufficient | This is exactly the purpose of the PoC: feedback from M2-PoC → fixes to the foundation in the same PR or M1.5-PR; the `tariffs` domain after fixes is closed at 100%, like the rest. M3 does not start until M2-PoC is green. | +| `AsyncTokenClient._request_token` is looped through the main auth provider | Internally, an independent `AsyncTransport` with `auth_provider=None` is created (mirror of sync `TokenClient._build_transport()`). | +| Sync behavior changed silently in Phase 1 | The M1 DoD includes a baseline-diff only on nodeids of existing tests with main; new async tests do not participate in the comparison. Any divergence on old nodeids blocks the merge. Phase 1a — a separate commit for bisect. | +| `_gen_reference.py` builds the reference only from sync `*/domain.py` or writes one common `::: avito.` → `Async` are silently absent from the reference, `make docs-strict` remains green, but publishing is incomplete | M1 must extend the builder (`public_domain_packages` picks up `async_domain.py`, `public_domain_classes` filters `Async` through `AsyncDomainObject` inheritance, `public_domain_methods` — through `value.__qualname__.startswith(f"{cls.__name__}.")`) and move `write_domain_pages()` to explicit class directives sync → async. Pre-flight records the current filter points. M2-PoC validates on `tariffs`. | +| The package version is not bumped in M-final → 2.1.0 release published under the old version | The M-final DoD requires `poetry version 2.1.0` + `## [2.1.0] - YYYY-MM-DD` in CHANGELOG in one PR. `git tag v2.1.0` after merge. | +| `_safe_summary_async` is moved to a separate module, sync `_safe_summary` stays in `client.py` → symmetric helpers in different files | The M-final DoD requires: either both in `avito/client.py`, or both in `avito/summary/_helpers.py`. Partial extraction is forbidden. | +| Concurrent iteration of one `AsyncPaginatedList` mutates a shared `_cursor` → the user gets silent data corruption | Fail-fast contract: a second `__aiter__` on an active instance raises `RuntimeError`; fan-out is done via `await materialize()` or a separate `AsyncPaginatedList` per consumer. | +| English in new error messages of `async_domain.py` (STYLEGUIDE.md violation) | The M3…M12 DoD includes an explicit item "error messages in Russian only"; code review verifies every `raise ("...")`. | +| `AsyncSwaggerFakeTransport` is not synchronized with sync `SwaggerFakeTransport` | Added in M1 as a thin async mirror over shared schema/argument helpers. `tests/contracts/test_async_swagger_contracts.py` walks discovered `variant="async"` bindings at each stage and in M-final covers all 204 operations. | +| `pytest-asyncio` 0.23+ emits `PytestDeprecationWarning` without `asyncio_default_fixture_loop_scope` → noise accumulates in pytest output, blocks future enabling of `filterwarnings = error` | M1 must add `asyncio_default_fixture_loop_scope = "function"` in `[tool.pytest.ini_options]` next to `asyncio_mode = "strict"`. At the time of M1, `filterwarnings = error` is not yet enabled (preventive defense). Locked in the M1 DoD. | +| `_validate_factory(variant="async")` fails on async auth bindings in M1 (no domain factory on `AsyncAvitoClient`) OR misses a missing async factory in M3+ | Class-gated implementation: factory-check is skipped on async bindings without `Async` in the domain and on bindings without `factory` in the decorator. The test `test_validate_factory_async_skips_unported_classes` locks in the behavior for M1, the test `test_validate_factory_async_requires_factory_for_ported_class` — for M2-PoC+. | +| `_operation_specs_for_sdk_method` does not find a spec from `async_domain.py`, and Phase 1b runs into this in the middle without a plan | The fallback is laid out **before** the start of M1 (see Swagger section): primary — AST resolution from the source file, secondary — class-level `__operation_specs__`. The pre-flight smoke test selects one of the options **before** opening the M1 PR; the decision is recorded in the PR description. | +| `AsyncOperationExecutor` takes retry only from the argument or only from `spec.retry` → divergence with sync executor goes unnoticed | The M1 DoD includes a parameterized test `test_executor_retry_resolution_matches_sync` on three triples `(retry, spec.retry, expected)`, comparing the result with sync `OperationExecutor`. | +| `httpx.AsyncClient` with default limits + unlimited fan-out in M-final convenience methods → pool starvation | M1 fixes default `httpx.Limits` (no override). The M-final DoD requires fan-out ≤ 6 in-flight tasks per aggregator. The current sync aggregators fit within this limit (max ~5 branches in `account_health`). | +| `review_summary` async with TaskGroup cancels an in-flight optional `reviews` task on a required `rating` error → changes sync semantics | `review_summary` async **must** be sequential reviews-then-rating without TaskGroup, as recorded in the classification table and the "Important TaskGroup subtlety" block. The M-final DoD code review checklist explicitly verifies this. | +| `AsyncAuthProvider.invalidate_token` is made a coroutine with `async with self._refresh_lock` → false protection, increased latency of 401-handling, divergence with sync | The contract is explicitly `def invalidate_token(self) -> None`, no await; the test `test_invalidate_token_is_sync_and_idempotent` locks in synchronicity and idempotency. | +| `AsyncTransport.request()` forgets to call `await self._rate_limiter.acquire()` before the httpx call → state is updated (via `observe_response`), but real serialization does not work, parallel coroutines go out in a batch | Step 3 of the `AsyncTransport.request()` contract explicitly mirrors sync `Transport.request()` line 148: `await self._rate_limiter.acquire()` before each `await self._client.request(...)`. Locked in by the test `test_request_acquires_rate_limiter_before_httpx_call` (5 parallel coroutines on one transport — `RateLimitState._tokens` is updated one at a time before the httpx call). The paired test `test_request_calls_observe_response_after_success` locks in the post-condition. | +| The binary branch of `AsyncOperationExecutor` differs from sync (different helper, different `BinaryResponse` form) → divergence for `OrderLabel.download()` and analogs | Module-level `_request_binary_async(transport, *, spec, path, ...)` mirrors sync `_request_binary` (`avito/core/operations.py:254-278`), both in the same file, both accepting their own `*OperationTransport` Protocol. The test `test_binary_branch_uses_request_binary_async_helper` locks in matching of `BinaryResponse` fields. The M12 domain test `OrderLabel.download()` via `AsyncSwaggerFakeTransport` is a mandatory final gate. | +| The location of `AsyncRateLimiter` is chosen in PR review → bikeshedding, risk of blurring async infrastructure into `async_transport.py` | Locked in: **`avito/core/_async_rate_limit.py`**, symmetrically with sync `avito/core/rate_limit.py`. Any deviation requires explicit justification in the PR description. | +| The list of deprecated methods in `cpa`/`ads` becomes outdated → the async-aware wrapper in `deprecation.py` misses a case, M6/M11 catch the paradox in the middle of development | Pre-flight grep `@deprecated_method` in `avito/cpa/` and `avito/ads/` records the exact number (at the time of writing the plan: 3 + 4 = 7) and locations (`cpa/domain.py:491,541,585`, `ads/domain.py:1416,1457,1523,1558`). Any divergence between pre-flight grep and the current state — update of the sequencing table before the start of M1. | +| The M-final verification script hardcodes ~50 `Async` names → any addition/rename of a class requires manual editing of the script | The M-final script gets the list from `scripts.lint_async_parity.iter_async_classes()` — the single source of truth. The linter must export this function as a public API of the module. | +| `AsyncFakeTransport.as_client(user_id=N)` without `authenticated=True` behaves unclearly for domain tests → the test setup violates sync parity | The contract `as_client(user_id=N, authenticated=False)` is explicitly described: `_resolve_user_id` takes `settings.user_id` without a network request, `auth_provider=None` skips the `Authorization` header. Symmetrically with sync `FakeTransport.as_client(user_id=N)`. Locked in by the test `test_as_client_user_id_skips_self_lookup`. |