diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d5287d6..2d04348 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -35,7 +35,20 @@ "Bash(grep -n \"^\\\\`\\\\`\\\\`\" /Users/n.baryshnikov/Projects/avito_python_api/docs/site/how-to/security-practices.md)", "Bash(pip show *)", "Bash(awk -F'|' '{gsub\\(/^ +| +$/,\"\",$2\\); gsub\\(/^ +| +$/,\"\",$6\\); print $2 \":\" $6}')", - "Bash(make qa-docs *)" + "Bash(make qa-docs *)", + "WebFetch(domain:developers.avito.ru)", + "Bash(curl -s -L --max-time 30 -A \"Mozilla/5.0 \\(Macintosh; Intel Mac OS X 10_15_7\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" \"https://developers.avito.ru/api-catalog\")", + "Bash(curl -s -L --max-time 30 -A \"Mozilla/5.0 \\(Macintosh; Intel Mac OS X 10_15_7\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" \"https://developers.avito.ru/dstatic/build/open-api-dev-portal.16ee9b7cf4f5ce68f019.js\")", + "Bash(grep -oE '\"\\(/[a-z0-9_-]+\\){2,}/[a-z0-9_-]+\"')", + "Bash(curl -s -L --max-time 30 -A \"Mozilla/5.0 \\(Macintosh; Intel Mac OS X 10_15_7\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" \"https://developers.avito.ru/web/1/openapi/list\")", + "Bash(curl -s -L --max-time 30 -A \"Mozilla/5.0 \\(Macintosh; Intel Mac OS X 10_15_7\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" \"https://developers.avito.ru/web/1/openapi/info/messenger\")", + "Bash(curl -s -L --max-time 30 -A \"Mozilla/5.0 \\(Macintosh; Intel Mac OS X 10_15_7\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" \"https://developers.avito.ru/web/1/openapi/info/auth\")", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 \"https://api.avito.ru/docs/public/messenger.yaml\")", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 \"https://developers.avito.ru/swagger/messenger.yaml\")", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 \"https://developers.avito.ru/api-catalog/messenger/swagger.json\")", + "Bash(curl -s --max-time 10 \"https://developers.avito.ru/swagger/messenger.yaml\")", + "Bash(curl -s --max-time 10 \"https://developers.avito.ru/api-catalog/messenger/swagger.json\")", + "Bash(awk -F'|' '{print $15}')" ] } } diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2c443e2..4b36eac 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,7 +1,7 @@ ## Проверки - [ ] `make check` проходит локально или в CI. -- [ ] `make docs-strict` проходит, если изменены README, docs, публичные сигнатуры или inventory. +- [ ] `make docs-strict` проходит, если изменены README, docs, публичные сигнатуры или Swagger bindings. - [ ] README/tutorials/how-to примеры соответствуют актуальным публичным сигнатурам SDK. -- [ ] Новая публичная операция добавлена в `docs/avito/inventory.md` и покрыта reference. +- [ ] Новая публичная операция связана со Swagger operation binding и покрыта reference. - [ ] Публичное переименование: alias сохранён, `DeprecationWarning` добавлен, `CHANGELOG.md` обновлён в секции `Deprecated`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38260c3..a6339a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,9 @@ jobs: - name: Install dependencies run: poetry install --no-interaction --with docs + - name: Run strict Swagger coverage gate + run: make swagger-coverage + - name: Run quality gate run: make check diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 65eb360..0f7eb33 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -40,21 +40,7 @@ jobs: run: make docs-strict - name: Build docs reports - run: | - poetry run python scripts/check_inventory_coverage.py --output inventory-coverage-report.json - poetry run python scripts/check_spec_inventory_sync.py --output spec-inventory-report.json - poetry run python scripts/check_reference_public_surface.py --output reference-public-report.json - poetry run python scripts/check_public_docstrings.py --output docstring-contract-report.json - poetry run python scripts/check_changelog_sections.py --output changelog-sections-report.json - poetry run bandit -r avito -lll -f json -o bandit-report.json - poetry run python scripts/build_docs_quality_report.py \ - --inventory-report inventory-coverage-report.json \ - --spec-report spec-inventory-report.json \ - --reference-report reference-public-report.json \ - --docstring-report docstring-contract-report.json \ - --changelog-report changelog-sections-report.json \ - --bandit-report bandit-report.json \ - --output docs-quality-report.json + run: make docs-report - name: Prepare local docs root for link checking run: ln -s . site/avito_python_api @@ -64,13 +50,7 @@ jobs: with: name: docs-contract-reports path: | - inventory-coverage-report.json - spec-inventory-report.json - reference-public-report.json - docstring-contract-report.json - changelog-sections-report.json - bandit-report.json - docs-quality-report.json + swagger-bindings-report.json - name: Check links uses: lycheeverse/lychee-action@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9da17ad..13e08a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,6 +37,9 @@ jobs: TAG_VERSION="${GITHUB_REF_NAME#v}" poetry version "$TAG_VERSION" + - name: Run strict Swagger coverage gate + run: make swagger-coverage + - name: Run quality gate run: make check diff --git a/.gitignore b/.gitignore index 807e6a6..ce5988e 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports +swagger-bindings-report.json htmlcov/ .tox/ .nox/ diff --git a/CLAUDE.md b/CLAUDE.md index 9776ec5..7fd7c87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,8 +8,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co make test # run all tests make typecheck # mypy strict check on avito/ make lint # ruff check +make swagger-lint # strict Swagger binding coverage check make fmt # ruff format -make check # test → typecheck → lint → build (full gate) +make check # test → typecheck → lint → swagger-lint → build (full gate) make build # poetry build # single test @@ -41,18 +42,48 @@ poetry run pytest tests/test_facade.py::test_name **Testing**: `tests/fake_transport.py` provides `FakeTransport` — inject it instead of real HTTP. Tests are Arrange/Act/Assert, one scenario per test. Test names describe behavior, not the method under test. -## API coverage and inventory +## API coverage `docs/avito/api/` contains Swagger/OpenAPI specs (23 documents, 204 operations) — the authoritative source of truth for all API contracts. +The canonical SDK coverage map is built from Swagger operation bindings discovered on public domain methods, not from markdown inventory files. -`docs/avito/inventory.md` is the canonical mapping of every API operation to its SDK domain object and public method. Before implementing any new method, check the inventory to find: -- which `пакет_sdk` and `доменный_объект` it belongs to -- the expected `публичный_метод_sdk`, request/response type names -- whether the operation is deprecated (`deprecated: да` → wrap in a legacy domain object) +Public SDK methods are documented in `docs/site/reference/` and generated by the MkDocs reference builder from the actual package surface. All 204 operations from the specs must be covered. A missing method is a defect. -**When adding a new API method**: add it to the `## Операции` table in `docs/avito/inventory.md` (between the `operations-table:start/end` markers) following the existing format. +## Swagger binding subsystem -All 204 operations from the specs must be covered. A missing method is a defect. +The persistent subsystem context is documented in `docs/site/explanations/swagger-binding-subsystem.md`. + +Core invariant: + +```text +each Swagger operation -> exactly one discovered binding +each discovered SDK method -> exactly one Swagger operation +``` + +Multiple Swagger bindings on one public SDK method are forbidden. If one public scenario covers different upstream modes, expose separate documented SDK methods and keep compatibility wrappers unbound. + +When adding or changing a public method that corresponds to Avito API: + +- consult `docs/avito/api/*.json` first; +- add or update the public domain method, section client call, mapper and typed public models; +- add `@swagger_operation(...)` on the public domain method; +- do not put schemas, statuses, content types, request models, response models, error models, path params, or query params into the decorator; +- add or update class-level Swagger metadata when introducing a domain class; +- write a reference-ready docstring for the public method: business action, arguments, return model, pagination/dry-run/idempotency behavior when relevant, and common SDK exceptions; +- update `docs/site/how-to/` or `docs/site/explanations/` if the method introduces a workflow or a non-obvious contract; +- update `docs/site/explanations/swagger-binding-subsystem.md` when changing discovery, linter, JSON report, `SwaggerFakeTransport`, deprecated/legacy policy, or multi-operation binding policy. + +Minimum verification for API-related changes: + +```bash +make swagger-lint +poetry run pytest tests/core/test_swagger*.py tests/contracts/test_swagger_contracts.py +poetry run pytest tests/domains// +poetry run mypy avito +poetry run ruff check . +``` + +Before completing an API-surface change, run `make check`. If generated docs, docs snippets, coverage pages, or reference output changed, also run `make docs-strict`. ## STYLEGUIDE.md — strict compliance is mandatory @@ -64,6 +95,9 @@ The most critical prohibitions that must never be violated: - Returning `dict` or `Any` from public methods. - Using `resource_id` instead of concrete names (`item_id`, `order_id`). - Annotating `list[T]` where `PaginatedList[T]` is returned at runtime. +- Adding or changing an Avito API public method without a `@swagger_operation(...)` binding. +- Adding or changing an Avito API public method without a reference-ready docstring. +- Duplicating Swagger contract data inside binding decorators. - Making `AuthenticationError` a subclass of `AuthorizationError` (or vice versa). - Writing error messages in mixed languages (Russian only). - Injecting methods via `setattr`/`globals()` at runtime. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ae6779..178c846 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,18 +41,7 @@ TTFC показывает, за сколько минут новый польз 4. Запустите секундомер. 5. Выполните tutorial `getting-started.md` до успешного `get_self()`. 6. Остановите секундомер и запишите результат в минутах. -7. Перед сборкой отчёта передайте значение одним из способов: +7. Запишите результат в release notes или changelog релиза. -```bash -TTFC_MINUTES=8.5 make docs-report -``` - -или: - -```bash -printf "8.5\n" > ttfc-minutes.txt -make docs-report -``` - -`ttfc-minutes.txt` не коммитится. В CI релизного прогона можно передать -`--ttfc-minutes ` в `scripts/build_docs_quality_report.py`. +`make docs-report` генерирует machine-readable Swagger bindings report для +reference coverage; TTFC остаётся ручной release-проверкой. diff --git a/Makefile b/Makefile index 3fea838..5dfe5c1 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ export REGISTRY=10.11.0.9:5000 MKDOCS_ENV=DISABLE_MKDOCS_2_WARNING=true NO_MKDOCS_2_WARNING=1 -check: test typecheck lint build +check: test typecheck lint swagger-coverage build build: clean poetry build @@ -30,6 +30,15 @@ fmt: lint: poetry run ruff check . +swagger-update: + poetry run python scripts/download_avito_api_specs.py --clean + +swagger-lint: swagger-update + poetry run python scripts/lint_swagger_bindings.py --strict + +swagger-coverage: swagger-lint + poetry run pytest tests/core/test_swagger.py tests/core/test_swagger_discovery.py tests/core/test_swagger_linter.py tests/core/test_swagger_report.py tests/core/test_swagger_registry.py tests/contracts/test_swagger_contracts.py + minor: check poetry version minor @@ -47,20 +56,13 @@ docs-serve: docs-strict: $(MKDOCS_ENV) poetry run mkdocs build --strict - poetry run python scripts/check_readme_domain_coverage.py + poetry run python scripts/lint_swagger_bindings.py --strict poetry run pytest tests/docs/ docs-build: docs-strict docs-report: - poetry run python scripts/check_inventory_coverage.py --output inventory-coverage-report.json - poetry run python scripts/check_spec_inventory_sync.py --output spec-inventory-report.json - poetry run python scripts/check_reference_public_surface.py --output reference-public-report.json - poetry run python scripts/check_public_docstrings.py --output docstring-contract-report.json - poetry run python scripts/check_changelog_sections.py --output changelog-sections-report.json - poetry run python scripts/check_docs_examples.py --output reference-explanation-examples-report.json - poetry run bandit -r avito -lll -f json -o bandit-report.json - poetry run python scripts/build_docs_quality_report.py + poetry run python scripts/lint_swagger_bindings.py --json --strict --output swagger-bindings-report.json docs-check: docs-strict ln -sfn . site/avito_python_api diff --git a/README.md b/README.md index 6b0717a..f4893c7 100644 --- a/README.md +++ b/README.md @@ -3,21 +3,28 @@ [![CI](https://github.com/p141592/avito_python_api/actions/workflows/ci.yml/badge.svg)](https://github.com/p141592/avito_python_api/actions/workflows/ci.yml) [![Coverage Status](https://coveralls.io/repos/github/p141592/avito_python_api/badge.svg?branch=main)](https://coveralls.io/github/p141592/avito_python_api?branch=main) [![PyPI Downloads](https://img.shields.io/pypi/dm/avito-py.svg)](https://pypi.org/project/avito-py/) -[![API coverage](https://img.shields.io/badge/API%20coverage-204%2F204-success)](docs/avito/inventory.md) [![Docs](https://img.shields.io/badge/docs-latest-blue)](https://p141592.github.io/avito_python_api/) -`avito-py` — синхронный Python SDK для работы с Avito API через единый объектный фасад `AvitoClient`. +Отчёт покрытия Avito API: [покрытие API](https://p141592.github.io/avito_python_api/reference/api-report/). -Цели SDK: +## Быстрый старт -- скрыть transport, OAuth и retry-логику от пользовательского кода; -- возвращать типизированные `dataclass`-модели вместо сырого JSON; -- дать единый вход в доменные сценарии вида `avito.ad(...).get()` и `avito.chat(...).send_message(...)`; -- покрыть все swagger-документы из каталога [docs/avito/api](docs/avito/api). +Получение ключей — https://www.avito.ru/professionals/api -SDK является синхронным. Любая асинхронная поддержка, если она появится, будет жить в отдельном namespace `avito.aio` и никогда не будет смешана с sync-классами в одном модуле. +```python +from avito import AvitoClient -Каталог [docs/avito/api](docs/avito/api) рассматривается как upstream API contract. Эти файлы не редактируются вручную при развитии SDK: публичные модели, мапперы и тесты должны подстраиваться под documented shape из `docs/avito/api/*`. +with AvitoClient.from_env() as avito: + profile = avito.account().get_self() + ad = avito.ad(item_id=42, user_id=123).get() + +print(profile.name) +print(ad.title) +``` + +По умолчанию настройки читаются из переменных окружения с префиксом `AVITO_`. + +`avito-py` — синхронный Python SDK для работы с Avito API через единый объектный фасад `AvitoClient`. ## Установка @@ -33,23 +40,6 @@ pip install avito-py Требование к интерпретатору: Python `3.14` и выше в рамках ветки `3.x`. Репозиторий и релизный контур валидируются именно на Python `3.14`. -## Быстрый старт - -Получение ключей — https://www.avito.ru/professionals/api - -```python -from avito import AvitoClient - -with AvitoClient.from_env() as avito: - profile = avito.account().get_self() - ad = avito.ad(item_id=42, user_id=123).get() - -print(profile.name) -print(ad.title) -``` - -По умолчанию настройки читаются из переменных окружения с префиксом `AVITO_`. - ## Инициализация клиента SDK предоставляет три нормативных способа создания клиента — от самого простого к самому явному. @@ -374,4 +364,4 @@ git push origin v1.0.2 ## Документация репозитория - [STYLEGUIDE.md](STYLEGUIDE.md) — нормативные архитектурные правила -- [docs/avito/inventory.md](docs/avito/inventory.md) — матрица соответствия swagger-операций и публичного API SDK +- [docs/site/reference](docs/site/reference) — справочник публичного API SDK diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index c08de7f..d7162dc 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -658,6 +658,7 @@ Rules: - Public classes and methods must have short docstrings describing the contract. - A public method docstring must describe the returned SDK model and behavior on nullable/empty cases. - A public method docstring must also document: every supported per-operation override, whether the method is idempotent, and the exception types the method raises on the most common failure modes. +- Every new or changed public method that corresponds to an Avito API operation must have a docstring suitable for generated reference documentation. The docstring must identify the business action, public arguments, return model, pagination behavior if any, dry-run/idempotency behavior if any, and the common SDK exceptions. - Docstrings must not reference the shape of the raw upstream JSON, transport classes, or internal mapper objects. - Comments are used only where the intent cannot be expressed in code. - Comments must not duplicate what is obvious. @@ -677,6 +678,8 @@ Rules: - Every public domain must have at least one how-to snippet in the README. - Every new public contract must land with its reference stub and, when non-obvious, an explanation note. +- Every new public API method must be visible in generated reference documentation through its public signature and docstring. If the method introduces a new workflow, pagination shape, dry-run behavior, idempotency behavior, deprecation behavior, or testing utility, add or update a page in `docs/site/how-to/` or `docs/site/explanations/`. +- The Swagger binding subsystem is documented in `docs/site/explanations/swagger-binding-subsystem.md`. Changes to binding discovery, strict lint, JSON report format, `SwaggerFakeTransport`, deprecated/legacy policy, or multi-operation binding policy must update that page in the same change. - A CHANGELOG.md entry is mandatory for every public-facing change and references the affected contract sections. ## Testing @@ -704,7 +707,7 @@ What is not tested: - That a function returns `None` when the input is `None`. - That importing a module does not raise an exception. - Logic fully implemented by a third-party library without customization. -- Code-to-documentation consistency: a test must not verify that a README, inventory, docstring, or comment describes the current behavior. Documentation is not a contract — it describes code, not the other way around. If documentation is outdated, update it; do not write a test to track it. +- Code-to-documentation consistency: a test must not verify that a README, docstring, or comment describes the current behavior. Documentation is not a contract — it describes code, not the other way around. If documentation is outdated, update it; do not write a test to track it. - The presence of a specific method or attribute via `hasattr`. That is a syntax check, not a behavior check. If a method is renamed, the calling code will break, not a `hasattr` test. Criterion: if a test cannot be broken without violating a public contract or technical decision, the test is not needed. @@ -803,6 +806,16 @@ Rules: - nested models serialize recursively; - the result passes `json.dumps()` without exceptions. +**Swagger binding coverage** — must cover for every public method corresponding to an Avito API operation: + +- the method has exactly one binding to its upstream Swagger operation; +- every Swagger operation has exactly one discovered binding in strict mode; +- `spec`, method, path and optional `operation_id` match `docs/avito/api/`; +- `factory_args` and `method_args` match public factory/method signatures and use only allowed expressions; +- deprecated Swagger operations have `deprecated=True`, `legacy=True`, and runtime `DeprecationWarning`; +- `SwaggerFakeTransport` invokes every discovered binding without real HTTP; +- contract tests cover every numeric Swagger error response and verify SDK exception mapping. + ## API Documentation and Contract Coverage Avito API specifications are stored in the `docs/avito/api/` directory as Swagger/OpenAPI files. This is the authoritative source of truth for all API contracts. @@ -811,12 +824,39 @@ Rules: - Before implementing any new method or model, consult the specification in `docs/avito/api/`. - The SDK must cover **all** API methods described in `docs/avito/api/`. A method absent from the SDK but present in the specification is a defect. +- Every public SDK method that corresponds to an Avito API operation must have an explicit `@swagger_operation(...)` binding on the public domain method. The binding may contain only SDK-to-Swagger addressability and contract-test invocation metadata: `method`, `path`, optional `spec`, optional `operation_id`, optional `factory`, `factory_args`, `method_args`, `deprecated`, and `legacy`. +- 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. - 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. - Fields marked as required in the specification cannot be `T | None` in the public model without explicit justification. - Enum values in the SDK must match the allowed values from the specification — arbitrary extension is forbidden. +Required checks for API-related changes: + +```bash +make swagger-lint +poetry run pytest tests/core/test_swagger*.py tests/contracts/test_swagger_contracts.py +poetry run pytest tests/domains// +poetry run mypy avito +poetry run ruff check . +``` + +Before merging a complete API-surface change, run the full gate: + +```bash +make check +``` + +If the change affects generated docs, coverage pages, or documentation snippets, also run: + +```bash +make docs-strict +``` + ## Deprecation Policy and Backward Compatibility Breaking changes are a last resort. Users must be able to upgrade a minor version without touching their code. @@ -863,6 +903,10 @@ Rules: - Dead code: unused symbols, aliases, and imports. - Internal-layer request objects in public domain method signatures. - `**kwargs` on public methods: every accepted argument must be explicitly declared. +- Public API methods without generated-reference-ready docstrings. +- Public API methods corresponding to Avito API operations without `@swagger_operation(...)` bindings. +- Swagger binding decorators that duplicate upstream contract data such as schemas, statuses, content types, request models, response models, error models, path params, or query params. +- API coverage sources based on markdown inventory files instead of Swagger registry plus discovered bindings. - Positional passing of optional parameters: all optional parameters on public methods and the client constructor must be keyword-only. - Mutating a live `AvitoClient` (changing `base_url`, `auth`, timeouts, retry policy after construction). - Silent breaking changes: renaming or removing a public symbol without a deprecation period, a warning, and a `CHANGELOG.md` entry. diff --git a/action_plan.md b/action_plan.md new file mode 100644 index 0000000..f4beae1 --- /dev/null +++ b/action_plan.md @@ -0,0 +1,552 @@ +# Swagger Binding Architecture Action Plan + +## Контекст для быстрого восстановления + +Репозиторий: `/Users/n.baryshnikov/Projects/avito_python_api`. + +Цель новой архитектуры: заменить старую inventory-архитектуру машинно-проверяемой canonical coverage map на базе: + +1. Swagger/OpenAPI спецификаций из `docs/avito/api/*.json`. +2. `@swagger_operation(...)` bindings на публичных SDK domain methods. +3. `swagger-lint`, который строит и валидирует карту покрытия. + +`docs/avito/inventory.md` считается артефактом старой архитектуры. Он не должен быть источником истины и не должен участвовать в новых проверках покрытия. + +Текущий Swagger corpus: + +- 23 файла в `docs/avito/api`. +- 204 операции. +- 7 deprecated операций. + +Ключевой инвариант новой архитектуры: + +```text +Swagger operation +<-> exactly one @swagger_operation SDK method +-> SwaggerFakeTransport validates actual HTTP request/response +-> contract tests validate all statuses and errors from Swagger +``` + +Важные локальные точки: + +- `STYLEGUIDE.md` является нормативным документом и имеет приоритет. +- Публичный фасад: `avito/client.py`, класс `AvitoClient`. +- Публичные domain methods: `avito//domain.py`. +- Section clients: `avito//client.py`. +- Старые inventory-ссылки в `CLAUDE.md`, README, docs и генераторах документации мигрированы на binding discovery. +- `Makefile` сейчас имеет `check: test typecheck lint swagger-lint build`; strict `swagger-lint` уже входит в общий gate. + +Ограничения архитектуры: + +- Декоратор не должен дублировать Swagger-контракт. +- В binding запрещены response/request schemas, statuses, content types, response models, request models, error models. +- Swagger остаётся единственным источником HTTP method/path/parameters/body/status/schema/deprecated state. +- Binding описывает только соответствие SDK method операции Swagger и способ построить SDK-вызов для contract tests. + +## Design Decisions + +1. `docs/avito/inventory.md` retired. Новая canonical coverage map строится только из Swagger specs и discovered bindings. +2. Canonical bindings ставятся на публичные domain methods в `avito//domain.py`. +3. Section clients в `avito//client.py` не являются canonical public binding target, кроме заранее описанного legacy-исключения. +4. Summary/helper methods в `AvitoClient` не получают Swagger bindings, если они не соответствуют одной конкретной upstream Swagger operation. +5. Private methods, `_require_*` helpers и internal serialization helpers не участвуют в discovery. +6. Discovery не должен создавать `AvitoClient`, читать обязательные env vars, ходить в сеть или выполнять реальные HTTP calls. +7. `avito/core/swagger.py` не должен загружать Swagger files на import time. +8. `operation_id` является дополнительной проверкой, но не primary identity. Primary identity: `spec + method + normalized_path`. +9. Allowlist для deprecated/legacy/completeness исключений по умолчанию запрещён. Если он понадобится, запись должна иметь причину и дату удаления. +10. `deprecated` в binding сверяется только с operation-level `deprecated` из Swagger operation. Deprecated schema fields, enum values и properties не влияют на operation binding. + +## Decorator Contract + +Модуль: + +```text +avito/core/swagger.py +``` + +Публичный декоратор: + +```python +@swagger_operation( + method: str, + path: str, + *, + spec: str | None = None, + operation_id: str | None = None, + factory: str | None = None, + factory_args: Mapping[str, str] | None = None, + method_args: Mapping[str, str] | None = None, + deprecated: bool = False, + legacy: bool = False, +) +``` + +Binding model: + +```python +@dataclass(frozen=True, slots=True) +class SwaggerOperationBinding: + method: str + path: str + spec: str | None + operation_id: str | None + factory: str | None + factory_args: Mapping[str, str] + method_args: Mapping[str, str] + deprecated: bool + legacy: bool +``` + +Декоратор записывает metadata в `func.__swagger_binding__`, не меняет поведение метода и не читает Swagger files на import time. + +Class-level metadata на публичных domain objects: + +```python +__swagger_domain__: str +__swagger_spec__: str +__sdk_factory__: str +__sdk_factory_args__: Mapping[str, str] +``` + +Section clients могут иметь binding metadata только как заранее описанное legacy-исключение. + +## Path Normalization + +Правила normalizing для identity и линтера: + +1. `method` приводится к uppercase. +2. `path` хранится в Swagger format: `/path/{param}`. +3. Trailing slash удаляется, кроме path `/`. +4. Path parameter syntax кроме `{name}` запрещён. +5. Path остаётся case-sensitive. +6. Primary operation key: `spec + method + normalized_path`. +7. Если `spec` не указан, auto-resolve по `method + normalized_path` разрешён только при ровно одном совпадении среди всех Swagger files. + +## Execution Modes + +`scripts/lint_swagger_bindings.py` должен поддерживать несколько режимов, чтобы внедрение можно было вести поэтапно: + +1. Default / non-strict mode: + - валидирует Swagger files; + - валидирует только уже найденные SDK bindings; + - не требует покрытия всех 204 операций; + - подходит для Этапов 1-5. +2. Strict mode: + - включает все default-проверки; + - требует, чтобы каждая Swagger operation имела ровно один SDK binding; + - включается в `make check` только после завершения доменной разметки. +3. JSON report mode: + - отдаёт machine-readable отчёт по операциям, bindings, missing/duplicate/ambiguous cases; + - используется docs/reference generator и coverage badge; + - заменяет старые inventory-derived reports. + +CLI contract: + +```bash +poetry run python scripts/lint_swagger_bindings.py +poetry run python scripts/lint_swagger_bindings.py --strict +poetry run python scripts/lint_swagger_bindings.py --json +poetry run python scripts/lint_swagger_bindings.py --json --output swagger-bindings-report.json +``` + +Exit codes: + +- `0`: ошибок нет; +- `1`: найдены validation errors; +- `2`: ошибка CLI usage, чтения specs или некорректной среды запуска. + +## JSON Report Contract + +JSON report должен быть стабильным API для docs/reference generator и badge. + +Минимальная структура: + +```json +{ + "summary": { + "specs": 23, + "operations_total": 204, + "deprecated_operations": 7, + "bound": 0, + "unbound": 204, + "duplicate": 0, + "ambiguous": 0 + }, + "operations": [], + "bindings": [], + "errors": [] +} +``` + +`operations[]` содержит `spec`, `method`, `path`, `operation_id`, `deprecated`, `status`, `binding`. + +`bindings[]` содержит `module`, `class`, `method`, `operation_key`, `factory`, `factory_args`, `method_args`. + +`errors[]` содержит `code`, `message`, `operation_key`, `sdk_method`. + +## Definition of Done + +Критерии готовности этапов: + +- Этап 0 готов, когда в документации больше нет утверждения, что inventory является canonical source of truth. +- Этап 1 готов, когда unit-тесты декоратора проходят, а `avito/core/swagger.py` не импортирует и не читает `docs/avito/api`. +- Этап 2 готов, когда registry стабильно извлекает 23 specs, 204 operations и 7 deprecated operations. +- Этап 3 готов, когда discovery на пустой/частичной разметке не требует env vars, сети и создания `AvitoClient`. +- Этап 4 готов, когда `make swagger-lint` работает в non-strict режиме и возвращает стабильные actionable error codes. +- Этап 5 готов по домену, когда все его public operation methods имеют bindings и проходят `make swagger-lint` в non-strict режиме. +- Этап 6 готов, когда strict mode подтверждает ровно один binding на каждую из 204 Swagger operations. +- Этап 7 готов, когда все `factory_args` и `method_args` проходят validation against Swagger parameters/request body. +- Этап 8 готов, когда contract tests проверяют generated SDK calls через `SwaggerFakeTransport` без реального HTTP. +- Этап 9 готов, когда `make check` включает `swagger-lint --strict` и проходит полностью. + +## Этап 0. Зафиксировать миграционное решение + +1. Обновить `CLAUDE.md`, README и docs: заменить “inventory is canonical mapping” на “Swagger bindings are canonical coverage map”. +2. Проверить, что старые `check_inventory_*` скрипты и ссылки больше не участвуют в `Makefile`, docs или CI. +3. Зафиксировать, что documentation/reference должны генерироваться из binding discovery, а не из markdown inventory. + +## Этап 1. Базовый декоратор + +1. Создать `avito/core/swagger.py`. +2. Реализовать `SwaggerOperationBinding`: + - `@dataclass(frozen=True, slots=True)`; + - `method` normalizes to uppercase; + - `factory_args` и `method_args` stored as immutable mappings; + - без загрузки Swagger на import time. +3. Реализовать `swagger_operation(...)` с публичной сигнатурой из раздела `Decorator Contract`. +4. Экспортировать публичный API из `avito/core/__init__.py`, если это соответствует локальному паттерну. +5. Добавить unit-тесты: + - metadata пишется в `func.__swagger_binding__`; + - поведение decorated method не меняется; + - mappings immutable; + - лишние/запрещённые kwargs невозможны через сигнатуру. + +## Этап 2. Swagger registry для линтера + +1. Создать импортируемый parser/helper-модуль для registry и discovery. +2. Оставить `scripts/lint_swagger_bindings.py` тонким CLI wrapper-ом. +3. Загружать все `docs/avito/api/*.json`. +4. Извлекать операции в структуру: + - `spec`; + - `method`; + - `path`; + - `operation_id`; + - `deprecated`; + - path/query/header parameters; + - request body metadata. +5. Проверять базовую валидность specs: + - JSON валиден; + - есть `paths`; + - operation keys уникальны; + - path parameters из URL совпадают с описанными параметрами. + +## Этап 3. Discovery SDK bindings + +1. В discovery-коде импортировать пакет `avito` без создания `AvitoClient`. +2. Обойти публичные domain-классы из `avito//domain.py` и найти методы с `__swagger_binding__`. +3. Для каждого binding вычислить effective metadata: + - method-level values; + - class-level `__swagger_spec__`, `__sdk_factory__`, `__sdk_factory_args__`; + - auto-resolve только если совпадение однозначно. +4. Сформировать canonical map: `Swagger operation key -> SDK method`. +5. Явно игнорировать section clients, private methods, summary methods и internal helpers. + +## Этап 3.5. Baseline coverage report + +1. Реализовать non-authoritative baseline report на базе Swagger registry и binding discovery. +2. Для каждой операции показать: + - `spec`; + - `method`; + - `path`; + - `operation_id`; + - `deprecated`; + - binding status: `bound`, `unbound`, `duplicate`, `ambiguous`. +3. Если возможно безопасно угадать SDK target, показывать guessed domain/class/method как подсказку, но не как источник истины. +4. Использовать report как рабочий инструмент разметки доменов. +5. Не возвращать `docs/avito/inventory.md` и не делать markdown inventory canonical. + +## Этап 4. MVP линтера + +1. Реализовать проверки: + - binding указывает на существующую Swagger operation; + - `spec` существует; + - `operation_id`, если указан, совпадает; + - duplicate bindings запрещены; + - `deprecated` / `legacy` согласованы со Swagger; + - factory существует на `AvitoClient`. +2. Добавить signature validation для factory и decorated SDK method: + - `factory_args` соответствуют сигнатуре factory; + - `method_args` соответствуют сигнатуре SDK method; + - required параметры покрыты mapping-ом; + - лишние mapping keys запрещены. +3. Сделать actionable ошибки с кодами вида `[SWAGGER_BINDING_NOT_FOUND]`. +4. Добавить `make swagger-lint`, запускающий non-strict mode. +5. Пока не включать strict completeness в `make check`, если binding-и ещё не расставлены на все 204 операции. + +## Этап 4.5. Deprecated / legacy policy + +1. Зафиксировать policy для 7 operation-level deprecated Swagger operations. +2. Определить, когда binding обязан иметь `legacy=True`. +3. Проверить, что deprecated public methods имеют runtime deprecation behavior, если это требуется STYLEGUIDE. +4. Запретить `legacy=True` на non-deprecated operation без явного исключения. +5. Если исключения всё же понадобятся, создать отдельный allowlist-файл с причиной и датой удаления. + +Policy: + +- Operation-level `deprecated: true` из Swagger требует `deprecated=True` и `legacy=True` в binding. +- Deprecated binding обязан указывать на public SDK method с runtime `DeprecationWarning` через `deprecated_method(...)`. +- `legacy=True` на non-deprecated Swagger operation запрещён без отдельного allowlist-исключения. +- Deprecated schema fields, properties и enum values не создают deprecated/legacy binding requirement. +- Текущие operation-level deprecated операции: `CPAАвито.json GET /cpa/v1/call/{call_id}`, `CPAАвито.json POST /cpa/v2/balanceInfo`, `CPAАвито.json POST /cpa/v2/callById`, `Автозагрузка.json GET /autoload/v1/profile`, `Автозагрузка.json POST /autoload/v1/profile`, `Автозагрузка.json GET /autoload/v2/reports/last_completed_report`, `Автозагрузка.json GET /autoload/v2/reports/{report_id}`. + +## Этап 4.75. Factory/domain mapping inventory + +1. Построить рабочую таблицу `AvitoClient factory -> domain class -> spec candidates`. +2. Проверить, что каждый factory можно introspect-ить без создания `AvitoClient`. +3. Выявить операции, которые сейчас представлены summary/helper methods и не должны получать direct binding. +4. Использовать таблицу как подготовку к доменной разметке, но не делать её source of truth. + +Результат этапа хранится как non-authoritative `factory_mapping` section в JSON report. Она помогает расставлять domain bindings, но canonical coverage по-прежнему считается только из Swagger operations и discovered `@swagger_operation` bindings. + +## Этап 5. Расстановка binding-ов по доменам + +Делать маленькими PR/commit-ами по одному домену: + +1. `accounts`, `tariffs`, `ratings` как самые маленькие. +2. `messenger`. +3. `promotion`. +4. `ads` / autoload legacy. +5. `orders` / delivery / stock. +6. `jobs`. +7. `cpa` / calltracking. +8. `autoteka`. +9. `realty`. + +Для каждого домена: + +- добавить class-level metadata; +- расставить `@swagger_operation`; +- описать `factory_args` и `method_args`; +- запускать `make test`, `make typecheck`, `make lint`, `make swagger-lint`; +- не добавлять request/response schemas в decorators. + +Для каждого домена в changelog фиксировать: + +- сколько операций стало bound; +- сколько осталось unbound; +- какие deprecated/legacy решения приняты; +- какие проверки запускались. + +## Этап 6. Strict completeness + +1. Включить проверку: каждая из 204 Swagger operations имеет ровно один binding. +2. Включить проверку: каждый binding уникален. +3. Перевести `make swagger-lint` на strict mode. +4. Сделать `make swagger-lint` частью `make check`. +5. Обновить badge/docs покрытия: coverage теперь считается из Swagger registry + binding discovery. + +## Этап 6.5. Documentation migration + +1. Перевести generated reference/coverage docs на JSON report или импортируемый discovery API. +2. Удалить или переписать оставшиеся inventory-derived docs paths. +3. Обновить README badge/description: coverage считается из Swagger bindings. +4. Проверить, что `docs/site/reference/coverage.md` и related pages не называют inventory источником истины. + +## Этап 7. Path expression validation + +1. Проверять `path.` против path params. +2. Проверять `query.` против query params. +3. Проверять `header.` против header params. +4. Проверять `body` и `body.` против request body. +5. Ввести test constants registry для `constant.`. +6. Запретить любые expressions вне whitelist. + +## Этап 8. Contract tests + +1. Реализовать `SwaggerFakeTransport`. +2. На основе binding-а строить SDK вызов из generated request data. +3. Request-contract tests: проверять, что SDK делает HTTP request, соответствующий Swagger: + - method; + - path; + - path/query/header params; + - body shape; + - content type. +4. Response-contract tests: проверять happy-path response mapping в typed SDK models. +5. Error-contract tests: проверять statuses и exception mapping из Swagger. +6. Отдельно проверить deprecated/legacy операции. +7. Начать с read-only операций, потом расширить на write-операции и idempotency. + +## Этап 9. Финальный gate + +1. Запустить: + - `make test`; + - `make typecheck`; + - `make lint`; + - `make swagger-lint`; + - `make build`. +2. Затем `make check`, где `swagger-lint` уже должен быть включён. +3. Проверить, что старый inventory нигде не упоминается как источник истины. + +## Этап 10. Устранение выявленных несоответствий после выполнения плана + +Цель: отдельным новым этапом закрыть несоответствия, найденные после выполнения Этапов 0-9, не переписывая историю уже выполненных пунктов. + +Не входит в этот этап: + +- дефект публичного Swagger corpus с несовпадением `{userId}` / `{itemId}` и `pathUserId` / `pathItemId`; +- patch/override pipeline для upstream specs. + +### 10.1. Запрет нескольких bindings на один SDK method + +Требование: несколько Swagger bindings на один SDK method запрещены. Каждая Swagger operation должна иметь собственный discovered SDK method target. + +1. Найти все SDK methods, на которых discovery видит больше одного Swagger binding. +2. Для каждого случая выбрать явное разделение: + - отдельные public SDK methods, если операции являются разными пользовательскими действиями; + - отдельные documented wrappers, если один сценарий раньше скрывал несколько upstream modes; + - отдельные low-level auth SDK targets для token operations, если они остаются non-domain binding exception. +3. Удалить поддержку stacked `@swagger_operation(...)` из декоратора: + - не накапливать `func.__swagger_bindings__`; + - повторная установка binding на метод должна быть ошибкой или должна явно запрещаться тестом. +4. Обновить discovery: + - читать только `func.__swagger_binding__`; + - считать `__swagger_bindings__` или несколько bindings на одном method ошибкой совместимости. +5. Обновить linter: + - добавить error code `SWAGGER_BINDING_METHOD_MULTIPLE`; + - падать, если один `sdk_method` связан больше чем с одной operation; + - не вводить allowlist для multi-binding methods. +6. Обновить JSON report: + - оставить `bindings[]` как плоский список one binding per sdk_method; + - добавить ошибку в `errors[]`, если обнаружено legacy `__swagger_bindings__`. +7. Обновить docs: + - `docs/site/explanations/swagger-binding-subsystem.md`; + - `STYLEGUIDE.md`; + - `CLAUDE.md`; + - убрать формулировки, допускающие multi-operation SDK methods. +8. Добавить тесты: + - декоратор запрещает stacked bindings; + - discovery/linter ловят legacy `__swagger_bindings__`; + - strict report остаётся `204/204 bound`, `0 duplicate`, `0 ambiguous`, `0 errors`. + +### 10.2. Schema-aware validation для `body.` + +Требование: `body.` должен проверяться против request body schema/properties, а не только против наличия `requestBody`. + +1. Расширить `SwaggerRequestBody` в `avito/core/swagger_registry.py`: + - хранить `content_types`; + - хранить top-level body field names/properties; + - хранить флаг, что schema была успешно извлечена. +2. Добавить schema resolver для локальных `$ref`: + - `#/components/schemas/`; + - object schemas с `properties`; + - `allOf`/`oneOf`/`anyOf` только если можно безопасно извлечь top-level properties; иначе фиксировать unsupported schema state. +3. В `swagger_linter.py` изменить проверку `body.`: + - если `requestBody` отсутствует — текущая ошибка `SWAGGER_BINDING_BODY_MISSING`; + - если schema/properties доступны и поля нет — новая ошибка `SWAGGER_BINDING_BODY_FIELD_NOT_FOUND`; + - если schema не поддержана для field-level validation — новая actionable ошибка `SWAGGER_BINDING_BODY_SCHEMA_UNSUPPORTED`. +4. Добавить tests для registry: + - inline object schema; + - `$ref` schema; + - missing properties; + - unsupported schema shape. +5. Добавить tests для linter: + - valid `body.`; + - invalid `body.missing`; + - `body.` при unsupported schema; + - `body` остаётся валидным при любом request body. +6. Обновить JSON report/errors contract, если добавляются новые error codes. +7. Обновить `docs/site/explanations/swagger-binding-subsystem.md`, убрав оговорку, что field-level validation ещё не реализована. + +### 10.3. Усиление contract tests до полного binding/status coverage + +Требование: contract tests должны параметризованно покрывать все discovered bindings и все Swagger error status contracts, а не только representative samples/status categories. + +1. Добавить parametrized request-contract test по всем discovered bindings: + - загрузить registry; + - загрузить discovery; + - для каждого binding зарегистрировать success response; + - вызвать SDK method через `SwaggerFakeTransport.invoke_binding`; + - проверить, что request matched Swagger method/path и прошёл validation path/query/header/body/content-type. +2. Добавить deterministic payload generator: + - использовать Swagger response schema, где она доступна; + - использовать controlled payload registry для операций, где mapper требует доменно-специфичную форму; + - запрещать неописанные silent fallbacks, которые маскируют отсутствие payload contract. +3. Добавить parametrized error-contract test по всем Swagger error responses: + - для каждой operation и каждого numeric error status зарегистрировать `error_payload(status)`; + - вызвать соответствующий binding; + - проверить exception type по transport error mapping; + - проверить, что message/metadata не нарушают публичный error contract. +4. Добавить coverage assertions: + - количество request-contract cases равно количеству discovered bindings; + - количество error-contract cases равно количеству numeric Swagger error responses; + - deprecated operations входят в общий набор и дополнительно проверяют `DeprecationWarning`. +5. Если generated call невозможен для отдельной операции, тест должен падать. Allowlist для contract gaps не вводить без отдельного решения. +6. Обновить `SwaggerFakeTransport`, если нужно: + - добавить schema-aware success payload generation helpers; + - расширить test constants registry; + - улучшить diagnostics при невозможности построить вызов. +7. Обновить docs/testing notes: + - `docs/site/explanations/swagger-binding-subsystem.md`; + - `docs/site/explanations/testing-strategy.md`; + - `STYLEGUIDE.md`, если меняется обязательный verification set. + +### 10.4. Verification + +```bash +pytest tests/core/test_swagger.py tests/core/test_swagger_discovery.py tests/core/test_swagger_linter.py tests/core/test_swagger_report.py +pytest tests/core/test_swagger_registry.py tests/contracts/test_swagger_contracts.py +make swagger-lint +mypy avito +ruff check avito tests/core tests/contracts/test_swagger_contracts.py +make docs-strict +make check +``` + +## Критичный порядок + +Не начинать с `SwaggerFakeTransport`. Сначала нужна стабильная карта `Swagger operation -> SDK method`. + +Самый безопасный MVP: + +1. Декоратор. +2. Swagger registry. +3. Binding discovery. +4. Baseline coverage report. +5. Линтер в non-strict режиме. +6. Deprecated/legacy policy. +7. Factory/domain mapping inventory. +8. Доменные binding-и. +9. Strict completeness. +10. Documentation migration. +11. Contract tests. + +## Changelog + +Записи добавляются при выполнении или изменении плана. + +Формат: + +| Date | Change | Status | Verification | +|---|---|---|---| +| 2026-04-29 | Создан `action_plan.md` с контекстом, этапами реализации и changelog. | Done | Manual review | +| 2026-04-29 | Добавлены design decisions, execution modes, definition of done, baseline report, deprecated/legacy policy и documentation migration. | Done | Manual review | +| 2026-04-29 | Удалены ссылки на внешний контекст, добавлены decorator contract, path normalization, CLI/JSON report contract, factory inventory и разбиение contract tests. | Done | Manual review | +| 2026-04-29 | Выполнен Этап 0: README, CLAUDE/AGENTS, docs и PR template переведены на Swagger bindings; inventory checks удалены из docs CI. | Done | `rg` по inventory/check_inventory/canonical source; manual review | +| 2026-04-29 | Выполнен Этап 1: добавлен `avito/core/swagger.py`, экспорт core API и unit-тесты декоратора. | Done | `pytest tests/core/test_swagger.py`; `pytest`; `mypy avito`; `ruff check` | +| 2026-04-29 | Выполнен Этап 2: добавлен Swagger registry/parser, тонкий `lint_swagger_bindings.py` wrapper и тесты на corpus 23/204/7. | Done | `pytest tests/core/test_swagger_registry.py`; `python scripts/lint_swagger_bindings.py`; `mypy avito`; `ruff check` | +| 2026-04-29 | Выполнен Этап 3: добавлен discovery публичных domain bindings с class-level defaults, auto-resolve spec и canonical map. | Done | `pytest tests/core/test_swagger_discovery.py`; `pytest`; `mypy avito`; `ruff check` | +| 2026-04-29 | Выполнен Этап 3.5: добавлен baseline JSON report по Swagger registry + binding discovery, статусы `bound`/`unbound`/`duplicate`/`ambiguous` и JSON-режим CLI. | Done | `pytest tests/core/test_swagger.py tests/core/test_swagger_registry.py tests/core/test_swagger_discovery.py tests/core/test_swagger_report.py`; `ruff check avito/core/swagger_report.py scripts/lint_swagger_bindings.py tests/core/test_swagger_report.py`; `mypy avito`; `python scripts/lint_swagger_bindings.py --json --output /tmp/swagger-bindings-report.json` | +| 2026-04-29 | Выполнен Этап 4: добавлен MVP Swagger binding linter, validation ошибок binding/spec/operation_id/duplicate/deprecated/legacy/factory/signature, `make swagger-lint` и исправлены 2 path parameter mismatch в локальном Swagger corpus. | Done | `pytest tests/core/test_swagger.py tests/core/test_swagger_registry.py tests/core/test_swagger_discovery.py tests/core/test_swagger_report.py tests/core/test_swagger_linter.py`; `make swagger-lint`; `pytest`; `mypy avito`; `ruff check .`; `python scripts/lint_swagger_bindings.py --json --output /tmp/swagger-bindings-report-stage4.json` | +| 2026-04-29 | Выполнен Этап 4.5: зафиксирована deprecated/legacy policy для 7 operation-level deprecated operations, runtime deprecation metadata добавлена в `deprecated_method`, linter требует `legacy=True` и runtime warning для deprecated bindings. | Done | `pytest tests/core/test_swagger_registry.py tests/core/test_swagger_linter.py`; `make swagger-lint`; `pytest`; `mypy avito`; `ruff check .` | +| 2026-04-29 | Выполнен Этап 4.75: добавлен non-authoritative factory/domain mapping report для `AvitoClient factory -> domain class -> spec candidates`, introspection без создания клиента и список summary/helper methods без direct binding. | Done | `pytest tests/core/test_swagger_factory_map.py tests/core/test_swagger_report.py tests/core/test_swagger_linter.py`; `python scripts/lint_swagger_bindings.py --json --output /tmp/swagger-bindings-report-stage475.json`; `make swagger-lint`; `pytest`; `mypy avito`; `ruff check .` | +| 2026-04-29 | Выполнен Этап 5: расставлены Swagger bindings на все публичные domain operation methods: accounts 8, tariffs 1, ratings 4, messenger 18, promotion 24, ads/autoload 28, orders/delivery/stock 44, jobs 22, cpa/calltracking 13, autoteka 26, realty 7. Coverage report: bound 195, unbound 9, duplicate 0, ambiguous 0. Unbound остались только token operations и альтернативные ветки существующих мульти-режимных методов (`version=1`, `ids`, `extended=True`). | Done | `make swagger-lint`; `poetry run python scripts/lint_swagger_bindings.py --json --output /tmp/swagger-stage5-after.json`; AST-check public domain methods without bindings; `pytest`; `mypy avito`; `ruff check .` | +| 2026-04-29 | Выполнен Этап 6: strict completeness включён в CLI и `make check`; временные multi-binding targets для альтернативных upstream modes и OAuth token operations закрыли coverage до 204/204 bound, 0 unbound, 0 duplicate, 0 ambiguous. | Done | `poetry run python scripts/lint_swagger_bindings.py --json --strict --output /tmp/swagger-stage6.json`; `make swagger-lint`; `make check` | +| 2026-04-29 | Выполнен Этап 6.5: generated reference/coverage переведены на Swagger binding report, docs CI/docs-report используют strict report, оставшиеся ссылки на удалённые docs-report scripts убраны. | Done | `make docs-strict`; `make docs-report`; `rg` по inventory/check_inventory/удалённым docs scripts; manual review generated `site/reference/coverage` и `site/reference/operations` | +| 2026-04-29 | Выполнен Этап 7: linter валидирует `path.`, `query.`, `header.`, `body`/`body.` и `constant.` expressions; class-level factory defaults фильтруются по Swagger operation; исправлены bindings для autoload query/upload и Autoteka token. | Done | `pytest tests/core/test_swagger*.py`; `poetry run python scripts/lint_swagger_bindings.py --json --strict --output /tmp/swagger-stage7.json`; `make swagger-lint`; `mypy avito`; `ruff check avito tests/core/test_swagger_linter.py` | +| 2026-04-29 | Выполнен Этап 8: добавлен `SwaggerFakeTransport`, generated SDK call invocation по discovered bindings, request validation для method/path/path-query-header params/body/content-type, response happy-path mapping, error status mapping для всех Swagger error status categories и deprecated/legacy runtime warning contract. | Done | `poetry run pytest tests/contracts/test_swagger_contracts.py tests/core/test_swagger_registry.py tests/core/test_swagger_linter.py tests/contracts/test_public_surface.py`; `poetry run python scripts/lint_swagger_bindings.py --strict`; `poetry run mypy avito`; `poetry run ruff check avito tests/contracts/test_swagger_contracts.py tests/contracts/test_public_surface.py tests/core/test_swagger_registry.py`; `poetry run pytest`; `make check` | +| 2026-04-29 | Выполнен Этап 9: финальный gate пройден отдельными командами и через `make check`; проверено, что старый markdown inventory не упоминается как источник истины. | Done | `make test`; `make typecheck`; `make lint`; `make swagger-lint`; `make build`; `make check`; `rg` по `inventory`/`check_inventory`/`source of truth` | +| 2026-04-29 | Добавлен новый Этап 10 для устранения несоответствий после выполнения плана: запрет нескольких bindings на один SDK method, schema-aware validation для `body.`, усиление contract tests до полного binding/status coverage. Upstream Swagger mismatch не входит в этап и остаётся отдельной задачей. | Planned | Manual review | +| 2026-04-29 | Выполнен Этап 10.1: multi-binding SDK methods разделены на отдельные discovered targets, stacked decorators запрещены, discovery/linter ловят legacy `__swagger_bindings__`, docs больше не допускают multi-operation SDK methods. | Done | `poetry run pytest tests/core/test_swagger.py tests/core/test_swagger_discovery.py tests/core/test_swagger_linter.py tests/core/test_swagger_report.py`; `poetry run python scripts/lint_swagger_bindings.py --json --strict --output /tmp/swagger-10-1.json`; `jq` check for duplicate `sdk_method`; `make swagger-lint`; `poetry run mypy avito`; `poetry run ruff check avito tests/core docs/site/assets/_gen_reference.py` | +| 2026-04-29 | Выполнен Этап 10.2: `SwaggerRequestBody` хранит content types, top-level schema fields и schema extraction flag; registry извлекает inline/`$ref`/composed object properties; linter проверяет `body.` и выдаёт `SWAGGER_BINDING_BODY_FIELD_NOT_FOUND` / `SWAGGER_BINDING_BODY_SCHEMA_UNSUPPORTED`; bindings приведены к schema-aware expressions. | Done | `poetry run pytest tests/core/test_swagger_registry.py tests/core/test_swagger_linter.py`; `poetry run python scripts/lint_swagger_bindings.py --json --strict --output /tmp/swagger-10-2.json`; `make swagger-lint`; `poetry run mypy avito`; `poetry run ruff check avito tests/core docs/site/assets/_gen_reference.py`; `make check` | +| 2026-04-29 | Выполнен Этап 10.3: contract tests усилены до полного request coverage по 204 discovered bindings и полного error coverage по 639 numeric Swagger error responses; `SwaggerFakeTransport` получил generated success invocation для auth/domain bindings, controlled success payload registry и дополнительные SDK argument builders; исправлены выявленные Swagger request drift в query/header параметрах. | Done | `poetry run pytest tests/contracts/test_swagger_contracts.py`; full verification по 10.4 | +| 2026-04-29 | Выполнен Этап 10.4: пройден полный verification set после усиления contract tests; strict binding report подтверждает 204/204 bound, 0 unbound, 0 duplicate, 0 ambiguous, 0 validation errors. | Done | `poetry run pytest tests/core/test_swagger.py tests/core/test_swagger_discovery.py tests/core/test_swagger_linter.py tests/core/test_swagger_report.py`; `poetry run pytest tests/core/test_swagger_registry.py tests/contracts/test_swagger_contracts.py`; `make swagger-lint`; `poetry run mypy avito`; `poetry run ruff check avito tests/core tests/contracts/test_swagger_contracts.py`; `make docs-strict`; `make check` | diff --git a/avito/accounts/domain.py b/avito/accounts/domain.py index f87abec..e07f755 100644 --- a/avito/accounts/domain.py +++ b/avito/accounts/domain.py @@ -19,6 +19,7 @@ ) from avito.core import PaginatedList from avito.core.domain import DomainObject +from avito.core.swagger import swagger_operation def _serialize_datetime(value: datetime | None) -> str | None: @@ -29,8 +30,18 @@ def _serialize_datetime(value: datetime | None) -> str | None: class Account(DomainObject): """Доменный объект операций аккаунта.""" + __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", + ) def get_self(self) -> AccountProfile: """Получает профиль авторизованного пользователя. @@ -39,6 +50,12 @@ def get_self(self) -> AccountProfile: return AccountsClient(self.transport).get_self() + @swagger_operation( + "GET", + "/core/v1/accounts/{user_id}/balance", + spec="Информацияопользователе.json", + operation_id="getUserBalance", + ) def get_balance(self, user_id: int | None = None) -> AccountBalance: """Получает баланс пользователя. @@ -48,6 +65,12 @@ def get_balance(self, user_id: int | None = None) -> AccountBalance: resolved_user_id = self._resolve_user_id(user_id or self.user_id) return AccountsClient(self.transport).get_balance(user_id=resolved_user_id) + @swagger_operation( + "POST", + "/core/v1/accounts/operations_history", + spec="Информацияопользователе.json", + operation_id="postOperationsHistory", + ) def get_operations_history( self, *, @@ -75,8 +98,18 @@ def get_operations_history( class AccountHierarchy(DomainObject): """Доменный объект иерархии аккаунтов.""" + __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", + ) def get_status(self) -> AhUserStatus: """Получает статус пользователя в ИА. @@ -85,6 +118,12 @@ def get_status(self) -> AhUserStatus: return HierarchyClient(self.transport).get_status() + @swagger_operation( + "GET", + "/getEmployeesV1", + spec="ИерархияАккаунтов.json", + operation_id="getEmployeesV1", + ) def list_employees(self) -> EmployeesResult: """Получает список сотрудников иерархии. @@ -95,6 +134,12 @@ def list_employees(self) -> EmployeesResult: return HierarchyClient(self.transport).list_employees() + @swagger_operation( + "GET", + "/listCompanyPhonesV1", + spec="ИерархияАккаунтов.json", + operation_id="listCompanyPhonesV1", + ) def list_company_phones(self) -> CompanyPhonesResult: """Получает список телефонов компании. @@ -105,6 +150,13 @@ def list_company_phones(self) -> CompanyPhonesResult: return HierarchyClient(self.transport).list_company_phones() + @swagger_operation( + "POST", + "/linkItemsV1", + spec="ИерархияАккаунтов.json", + operation_id="linkItemsV1", + method_args={"employee_id": "body.employee_id", "item_ids": "body.item_ids"}, + ) def link_items( self, *, @@ -127,6 +179,13 @@ def link_items( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/listItemsByEmployeeIdV1", + spec="ИерархияАккаунтов.json", + operation_id="listItemsByEmployeeIdV1", + method_args={"employee_id": "body.employee_id"}, + ) def list_items_by_employee( self, *, diff --git a/avito/ads/__init__.py b/avito/ads/__init__.py index 3cc08dd..c48edbf 100644 --- a/avito/ads/__init__.py +++ b/avito/ads/__init__.py @@ -8,7 +8,15 @@ AutoloadProfile, AutoloadReport, ) -from avito.ads.enums import AdsActionStatus, AutoloadFieldType, AutoloadReportStatus, ListingStatus +from avito.ads.enums import ( + AdsActionStatus, + AutoloadAvitoStatus, + AutoloadFieldType, + AutoloadItemStatus, + AutoloadItemStatusDetail, + AutoloadReportStatus, + ListingStatus, +) from avito.ads.models import ( AccountSpendings, AdsActionResult, @@ -48,6 +56,7 @@ "AdPromotion", "AdStats", "AutoloadArchive", + "AutoloadAvitoStatus", "AutoloadFieldType", "AutoloadFee", "AutoloadFeesResult", @@ -56,6 +65,8 @@ "AutoloadProfile", "AutoloadProfileSettings", "AutoloadReport", + "AutoloadItemStatus", + "AutoloadItemStatusDetail", "AutoloadReportDetails", "AutoloadReportItem", "AutoloadReportItemsResult", diff --git a/avito/ads/client.py b/avito/ads/client.py index acfad1f..5f6d491 100644 --- a/avito/ads/client.py +++ b/avito/ads/client.py @@ -71,10 +71,28 @@ def _bounded_total(total: int | None, max_items: int | None) -> int | None: if max_items is None: return total if total is None: - return max_items + return None return min(total, max_items) +def _has_next_ads_page( + *, + page_item_count: int, + collected_count: int, + page_size: int, + total: int | None, + max_items: int | None, + already_collected: int, +) -> bool: + if page_item_count == 0 or page_size <= 0: + return False + if max_items is not None and already_collected + collected_count >= max_items: + return False + if total is not None: + return already_collected + collected_count < min(total, max_items or total) + return page_item_count >= page_size + + @dataclass(slots=True, frozen=True) class AdsClient: """Выполняет HTTP-операции по разделу объявлений.""" @@ -104,7 +122,12 @@ def list_items( """Получает список объявлений пользователя.""" resolved_page_size = page_size or limit - start_offset = offset if offset is not None else 0 if resolved_page_size is not None else None + start_offset = offset or 0 + first_page_number = ( + start_offset // resolved_page_size + 1 + if resolved_page_size is not None and resolved_page_size > 0 + else 1 + ) result = request_public_model( self.transport, "GET", @@ -114,21 +137,30 @@ def list_items( params={ "user_id": user_id, "status": status, - "limit": resolved_page_size, - "offset": start_offset, + "per_page": resolved_page_size, + "page": first_page_number, }, ) page_size = resolved_page_size if resolved_page_size and resolved_page_size > 0 else len(result.items) max_items = limit if limit is not None and limit >= 0 else None - first_items = result.items[:max_items] if max_items is not None else result.items + page_offset = start_offset % page_size if page_size > 0 else 0 + available_items = result.items[page_offset:] + first_items = available_items[:max_items] if max_items is not None else available_items total = _bounded_total(result.total, max_items) - resolved_offset = start_offset or 0 - start_page = resolved_offset // page_size + 1 if page_size > 0 else 1 first_page = JsonPage( items=list(first_items), total=total, - page=start_page, + source_total=result.total, + page=first_page_number, per_page=page_size if page_size > 0 else None, + has_next_page=_has_next_ads_page( + page_item_count=len(result.items), + collected_count=len(first_items), + page_size=page_size, + total=result.total, + max_items=max_items, + already_collected=0, + ), ) return Paginator( lambda page, cursor: self._fetch_ads_page( @@ -137,9 +169,9 @@ def list_items( status=status, page_size=page_size, max_items=max_items, - base_offset=resolved_offset, + first_page_number=first_page_number, ) - ).as_list(start_page=start_page, first_page=first_page) + ).as_list(start_page=first_page_number, first_page=first_page) def _fetch_ads_page( self, @@ -149,14 +181,13 @@ def _fetch_ads_page( status: ListingStatus | str | None, page_size: int, max_items: int | None, - base_offset: int, + first_page_number: int, ) -> JsonPage[Listing]: if page is None: raise ValidationError("Для операции требуется `page`.") - offset = (page - 1) * page_size - already_requested = max(offset - base_offset, 0) - remaining = max_items - already_requested if max_items is not None else None + already_collected = max(page - first_page_number, 0) * page_size + remaining = max_items - already_collected if max_items is not None else None if remaining is not None and remaining <= 0: return JsonPage(items=[], total=max_items, page=page, per_page=page_size) result = request_public_model( @@ -168,16 +199,25 @@ def _fetch_ads_page( params={ "user_id": user_id, "status": status, - "limit": min(page_size, remaining) if remaining is not None else page_size, - "offset": offset, + "per_page": min(page_size, remaining) if remaining is not None else page_size, + "page": page, }, ) items = result.items[:remaining] if remaining is not None else result.items return JsonPage( items=list(items), total=_bounded_total(result.total, max_items), + source_total=result.total, page=page, per_page=page_size, + has_next_page=_has_next_ads_page( + page_item_count=len(result.items), + collected_count=len(items), + page_size=page_size, + total=result.total, + max_items=max_items, + already_collected=already_collected, + ), ) def update_price( @@ -494,7 +534,7 @@ def get_ad_ids_by_avito_ids(self, *, avito_ids: list[int]) -> IdMappingResult: "/autoload/v2/items/ad_ids", context=RequestContext("ads.autoload.get_ad_ids_by_avito_ids"), mapper=map_id_mapping, - params={"avito_ids": ",".join(str(item) for item in avito_ids)}, + params={"query": ",".join(str(item) for item in avito_ids)}, ) def get_avito_ids_by_ad_ids(self, *, ad_ids: list[int]) -> IdMappingResult: @@ -506,7 +546,7 @@ def get_avito_ids_by_ad_ids(self, *, ad_ids: list[int]) -> IdMappingResult: "/autoload/v2/items/avito_ids", context=RequestContext("ads.autoload.get_avito_ids_by_ad_ids"), mapper=map_id_mapping, - params={"ad_ids": ",".join(str(item) for item in ad_ids)}, + params={"query": ",".join(str(item) for item in ad_ids)}, ) def list_reports( @@ -546,7 +586,7 @@ def get_items_info(self, *, item_ids: list[int]) -> AutoloadReportItemsResult: "/autoload/v2/reports/items", context=RequestContext("ads.autoload.get_items_info"), mapper=map_autoload_report_items, - params={"item_ids": ",".join(str(item) for item in item_ids)}, + params={"query": ",".join(str(item) for item in item_ids)}, ) def get_report(self, *, report_id: int) -> AutoloadReportDetails: diff --git a/avito/ads/domain.py b/avito/ads/domain.py index 888d19b..cdf0d37 100644 --- a/avito/ads/domain.py +++ b/avito/ads/domain.py @@ -39,6 +39,7 @@ from avito.core import PaginatedList, ValidationError from avito.core.deprecation import deprecated_method from avito.core.domain import DomainObject +from avito.core.swagger import swagger_operation from avito.core.validation import ( validate_non_empty_string, validate_string_items, @@ -89,9 +90,19 @@ def _serialize_stats_date(value: StatsDate | None) -> str | None: class Ad(DomainObject): """Доменный объект объявления.""" + __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", + ) def get(self) -> Listing: """Получает объявление по `item_id`. @@ -101,6 +112,12 @@ def get(self) -> Listing: item_id, user_id = self._require_ids() return AdsClient(self.transport).get_item(user_id=user_id, item_id=item_id) + @swagger_operation( + "GET", + "/core/v1/items", + spec="Объявления.json", + operation_id="getItemsInfo", + ) def list( self, *, @@ -125,6 +142,13 @@ def list( offset=offset, ) + @swagger_operation( + "POST", + "/core/v1/items/{item_id}/update_price", + spec="Объявления.json", + operation_id="updatePrice", + method_args={"price": "body.price"}, + ) def update_price( self, *, @@ -160,9 +184,19 @@ def _require_ids(self) -> tuple[int, int]: class AdStats(DomainObject): """Доменный объект статистики объявлений.""" + __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", + ) def get_calls_stats( self, *, @@ -184,6 +218,12 @@ def get_calls_stats( date_to=_serialize_stats_date(date_to), ) + @swagger_operation( + "POST", + "/stats/v1/accounts/{user_id}/items", + spec="Объявления.json", + operation_id="itemStatsShallow", + ) def get_item_stats( self, *, @@ -207,6 +247,12 @@ def get_item_stats( fields=fields or [], ) + @swagger_operation( + "POST", + "/stats/v2/accounts/{user_id}/items", + spec="Объявления.json", + operation_id="itemAnalytics", + ) def get_item_analytics( self, *, @@ -230,6 +276,12 @@ def get_item_analytics( fields=fields or [], ) + @swagger_operation( + "POST", + "/stats/v2/accounts/{user_id}/spendings", + spec="Объявления.json", + operation_id="accountSpendings", + ) def get_account_spendings( self, *, @@ -261,9 +313,20 @@ def _require_user_id(self) -> int: class AdPromotion(DomainObject): """Доменный объект продвижения объявления.""" + __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", + method_args={"item_ids": "body.item_ids"}, + ) def get_vas_prices( self, *, item_ids: list[int], location_id: int | None = None ) -> VasPricesResult: @@ -279,6 +342,13 @@ def get_vas_prices( location_id=location_id, ) + @swagger_operation( + "PUT", + "/core/v1/accounts/{user_id}/items/{item_id}/vas", + spec="Объявления.json", + operation_id="putItemVas", + method_args={"codes": "body.vas_id"}, + ) def apply_vas( self, *, @@ -312,6 +382,13 @@ def apply_vas( idempotency_key=idempotency_key, ) + @swagger_operation( + "PUT", + "/core/v2/accounts/{user_id}/items/{item_id}/vas_packages", + spec="Объявления.json", + operation_id="putItemVasPackageV2", + method_args={"package_code": "body.package_id"}, + ) def apply_vas_package( self, *, @@ -345,6 +422,13 @@ def apply_vas_package( idempotency_key=idempotency_key, ) + @swagger_operation( + "PUT", + "/core/v2/items/{item_id}/vas", + spec="Объявления.json", + operation_id="applyVas", + method_args={"codes": "body.slugs"}, + ) def apply_vas_direct( self, *, @@ -391,8 +475,18 @@ def _require_ids(self) -> tuple[int, int]: class AutoloadProfile(DomainObject): """Доменный объект профиля автозагрузки.""" + __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", + ) def get(self) -> AutoloadProfileSettings: """Получает профиль автозагрузки. @@ -401,6 +495,12 @@ def get(self) -> AutoloadProfileSettings: return AutoloadClient(self.transport).get_profile() + @swagger_operation( + "POST", + "/autoload/v2/profile", + spec="Автозагрузка.json", + operation_id="createOrUpdateProfileV2", + ) def save( self, *, @@ -423,6 +523,13 @@ def save( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/autoload/v1/upload", + spec="Автозагрузка.json", + operation_id="upload", + method_args={"url": "constant.url"}, + ) def upload_by_url(self, *, url: str, idempotency_key: str | None = None) -> UploadResult: """Загружает файл по ссылке. @@ -436,6 +543,12 @@ def upload_by_url(self, *, url: str, idempotency_key: str | None = None) -> Uplo idempotency_key=idempotency_key, ) + @swagger_operation( + "GET", + "/autoload/v1/user-docs/tree", + spec="Автозагрузка.json", + operation_id="userDocsTree", + ) def get_tree(self) -> AutoloadTreeResult: """Получает дерево категорий. @@ -444,6 +557,13 @@ def get_tree(self) -> AutoloadTreeResult: return AutoloadClient(self.transport).get_tree() + @swagger_operation( + "GET", + "/autoload/v1/user-docs/node/{node_slug}/fields", + spec="Автозагрузка.json", + operation_id="userDocsNodeFields", + method_args={"node_slug": "path.node_slug"}, + ) def get_node_fields(self, *, node_slug: str) -> AutoloadFieldsResult: """Получает поля категории. @@ -457,8 +577,18 @@ def get_node_fields(self, *, node_slug: str) -> AutoloadFieldsResult: class AutoloadReport(DomainObject): """Доменный объект отчета автозагрузки.""" + __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", + ) def get(self) -> AutoloadReportDetails: """Получает конкретный отчет v3. @@ -468,6 +598,12 @@ def get(self) -> AutoloadReportDetails: report_id = self._require_report_id() return AutoloadClient(self.transport).get_report(report_id=report_id) + @swagger_operation( + "GET", + "/autoload/v2/reports", + spec="Автозагрузка.json", + operation_id="getReportsV2", + ) def list( self, *, limit: int | None = None, offset: int | None = None ) -> PaginatedList[AutoloadReportSummary]: @@ -480,6 +616,12 @@ def list( return AutoloadClient(self.transport).list_reports(limit=limit, offset=offset) + @swagger_operation( + "GET", + "/autoload/v3/reports/last_completed_report", + spec="Автозагрузка.json", + operation_id="getLastCompletedReportV3", + ) def get_last_completed(self) -> AutoloadReportDetails: """Получает последний завершенный отчет. @@ -488,6 +630,12 @@ def get_last_completed(self) -> AutoloadReportDetails: return AutoloadClient(self.transport).get_last_completed_report() + @swagger_operation( + "GET", + "/autoload/v2/reports/{report_id}/items", + spec="Автозагрузка.json", + operation_id="getReportItemsById", + ) def get_items(self) -> AutoloadReportItemsResult: """Получает объявления из отчета. @@ -499,6 +647,12 @@ def get_items(self) -> AutoloadReportItemsResult: report_id = self._require_report_id() return AutoloadClient(self.transport).get_report_items(report_id=report_id) + @swagger_operation( + "GET", + "/autoload/v2/reports/{report_id}/items/fees", + spec="Автозагрузка.json", + operation_id="getReportItemsFeesById", + ) def get_fees(self) -> AutoloadFeesResult: """Получает списания по объявлениям отчета. @@ -508,6 +662,13 @@ def get_fees(self) -> AutoloadFeesResult: report_id = self._require_report_id() return AutoloadClient(self.transport).get_report_fees(report_id=report_id) + @swagger_operation( + "GET", + "/autoload/v2/items/ad_ids", + spec="Автозагрузка.json", + operation_id="getAdIdsByAvitoIds", + method_args={"avito_ids": "query.query"}, + ) def get_ad_ids_by_avito_ids(self, *, avito_ids: Sequence[int]) -> IdMappingResult: """Получает ad ids по avito ids. @@ -516,6 +677,13 @@ def get_ad_ids_by_avito_ids(self, *, avito_ids: Sequence[int]) -> IdMappingResul return AutoloadClient(self.transport).get_ad_ids_by_avito_ids(avito_ids=list(avito_ids)) + @swagger_operation( + "GET", + "/autoload/v2/items/avito_ids", + spec="Автозагрузка.json", + operation_id="getAvitoIdsByAdIds", + method_args={"ad_ids": "query.query"}, + ) def get_avito_ids_by_ad_ids(self, *, ad_ids: Sequence[int]) -> IdMappingResult: """Получает avito ids по ad ids. @@ -524,6 +692,13 @@ def get_avito_ids_by_ad_ids(self, *, ad_ids: Sequence[int]) -> IdMappingResult: return AutoloadClient(self.transport).get_avito_ids_by_ad_ids(ad_ids=list(ad_ids)) + @swagger_operation( + "GET", + "/autoload/v2/reports/items", + spec="Автозагрузка.json", + operation_id="getAutoloadItemsInfoV2", + method_args={"item_ids": "query.query"}, + ) def get_items_info(self, *, item_ids: Sequence[int]) -> AutoloadReportItemsResult: """Получает информацию по объявлениям автозагрузки. @@ -542,8 +717,20 @@ def _require_report_id(self) -> int: class AutoloadArchive(DomainObject): """Доменный объект архивных операций автозагрузки.""" + __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", + deprecated=True, + legacy=True, + ) @deprecated_method( symbol="AutoloadArchive.get_profile", replacement="autoload_profile().get", @@ -560,6 +747,14 @@ def get_profile(self) -> AutoloadProfileSettings: return AutoloadArchiveClient(self.transport).get_profile() + @swagger_operation( + "POST", + "/autoload/v1/profile", + spec="Автозагрузка.json", + operation_id="createOrUpdateProfile", + deprecated=True, + legacy=True, + ) @deprecated_method( symbol="AutoloadArchive.save_profile", replacement="autoload_profile().save", @@ -590,6 +785,14 @@ def save_profile( idempotency_key=idempotency_key, ) + @swagger_operation( + "GET", + "/autoload/v2/reports/last_completed_report", + spec="Автозагрузка.json", + operation_id="getLastCompletedReport", + deprecated=True, + legacy=True, + ) @deprecated_method( symbol="AutoloadArchive.get_last_completed_report", replacement="autoload_report().get_last_completed", @@ -606,6 +809,14 @@ def get_last_completed_report(self) -> LegacyAutoloadReport: return AutoloadArchiveClient(self.transport).get_last_completed_report() + @swagger_operation( + "GET", + "/autoload/v2/reports/{report_id}", + spec="Автозагрузка.json", + operation_id="getReportByIdV2", + deprecated=True, + legacy=True, + ) @deprecated_method( symbol="AutoloadArchive.get_report", replacement="autoload_report().get", diff --git a/avito/ads/enums.py b/avito/ads/enums.py index 0507650..668f769 100644 --- a/avito/ads/enums.py +++ b/avito/ads/enums.py @@ -31,6 +31,11 @@ class AutoloadFieldType(str, Enum): UNKNOWN = "__unknown__" STRING = "string" + INTEGER = "integer" + FLOAT = "float" + INPUT = "input" + SELECT = "select" + CHECKBOX = "checkbox" class AutoloadReportStatus(str, Enum): @@ -38,11 +43,90 @@ class AutoloadReportStatus(str, Enum): UNKNOWN = "__unknown__" DONE = "done" + PROCESSING = "processing" + SUCCESS = "success" + SUCCESS_WARNING = "success_warning" + ERROR = "error" + # Legacy item statuses kept for backward compatibility. + PROBLEM = "problem" + NOT_PUBLISH = "not_publish" + WILL_PUBLISH_LATER = "will_publish_later" + DUPLICATE = "duplicate" + WITHOUT_ID = "without_id" + DELETED = "deleted" + UPSTREAM_UNKNOWN = "unknown" + + +class AutoloadItemStatus(str, Enum): + """Статус объявления в отчете автозагрузки.""" + + UNKNOWN = "__unknown__" + SUCCESS = "success" + PROBLEM = "problem" + ERROR = "error" + NOT_PUBLISH = "not_publish" + WILL_PUBLISH_LATER = "will_publish_later" + DUPLICATE = "duplicate" + WITHOUT_ID = "without_id" + DELETED = "deleted" + UPSTREAM_UNKNOWN = "unknown" + + +class AutoloadItemStatusDetail(str, Enum): + """Подробный статус объявления в отчете автозагрузки.""" + + UNKNOWN = "__unknown__" + SUCCESS_ADDED = "success_added" + SUCCESS_ACTIVATED = "success_activated" + SUCCESS_ACTIVATED_UPDATED = "success_activated_updated" + SUCCESS_UPDATED = "success_updated" + SUCCESS_SKIPPED = "success_skipped" + PROBLEM_OBSOLETE = "problem_obsolete" + PROBLEM_PARAMS_CRITICAL = "problem_params_critical" + PROBLEM_PARAMS = "problem_params" + PROBLEM_PHONE = "problem_phone" + PROBLEM_IMAGES = "problem_images" + PROBLEM_VAS = "problem_vas" + PROBLEM_OTHER = "problem_other" + PROBLEM_SEVERAL = "problem_several" + ERROR_FEE = "error_fee" + ERROR_PARAMS = "error_params" + ERROR_PHONE = "error_phone" + ERROR_REJECTED = "error_rejected" + ERROR_BLOCKED = "error_blocked" + ERROR_DELETED = "error_deleted" + ERROR_OTHER = "error_other" + ERROR_SEVERAL = "error_several" + STOPPED_END_DATE_COMPLETE = "stopped_end_date_complete" + STOPPED_END_DATE_ERROR = "stopped_end_date_error" + DATE_IN_FUTURE = "date_in_future" + PUBLISH_LATER = "publish_later" + LINKER = "linker" + REMOVED_COMPLETE = "removed_complete" + REMOVED_ERROR = "removed_error" + NEED_SYNC = "need_sync" + DUPLICATE = "duplicate" + WITHOUT_ID = "without_id" + + +class AutoloadAvitoStatus(str, Enum): + """Статус объявления на Авито из отчета автозагрузки.""" + + UNKNOWN = "__unknown__" + ACTIVE = "active" + OLD = "old" + BLOCKED = "blocked" + REJECTED = "rejected" + ARCHIVED = "archived" + REMOVED = "removed" __all__ = ( "AdsActionStatus", + "AutoloadAvitoStatus", "AutoloadFieldType", + "AutoloadItemStatus", + "AutoloadItemStatusDetail", "AutoloadReportStatus", "ListingStatus", ) diff --git a/avito/ads/mappers.py b/avito/ads/mappers.py index cec4ce9..302d0bd 100644 --- a/avito/ads/mappers.py +++ b/avito/ads/mappers.py @@ -6,7 +6,15 @@ from datetime import datetime from typing import cast -from avito.ads.enums import AdsActionStatus, AutoloadFieldType, AutoloadReportStatus, ListingStatus +from avito.ads.enums import ( + AdsActionStatus, + AutoloadAvitoStatus, + AutoloadFieldType, + AutoloadItemStatus, + AutoloadItemStatusDetail, + AutoloadReportStatus, + ListingStatus, +) from avito.ads.models import ( AccountSpendings, AdsActionResult, @@ -176,7 +184,11 @@ def map_ads_list(payload: object) -> AdsListResult: data = _expect_mapping(payload) items = [map_ad_item(item) for item in _list(data, "items", "result", "resources")] - return AdsListResult(items=items, total=_int(data, "total", "count")) + meta = _mapping(data, "meta") + total = _int(data, "total", "count") + if total is None and meta is not None: + total = _int(meta, "total", "count") + return AdsListResult(items=items, total=total) def map_update_price_result(payload: object) -> UpdatePriceResult: @@ -419,10 +431,20 @@ def map_autoload_report_items(payload: object) -> AutoloadReportItemsResult: avito_id=_int(item, "avito_id", "avitoId"), status=map_enum_or_unknown( _str(item, "status"), - AutoloadReportStatus, - enum_name="ads.autoload_report_status", + AutoloadItemStatus, + enum_name="ads.autoload_item_status", ), title=_str(item, "title"), + status_detail=map_enum_or_unknown( + _str(item, "status_detail", "statusDetail"), + AutoloadItemStatusDetail, + enum_name="ads.autoload_item_status_detail", + ), + avito_status=map_enum_or_unknown( + _str(item, "avito_status", "avitoStatus"), + AutoloadAvitoStatus, + enum_name="ads.autoload_avito_status", + ), ) for item in _list(data, "items", "result") ] diff --git a/avito/ads/models.py b/avito/ads/models.py index e06c722..5fdfc82 100644 --- a/avito/ads/models.py +++ b/avito/ads/models.py @@ -5,7 +5,15 @@ from dataclasses import dataclass, field from datetime import datetime -from avito.ads.enums import AdsActionStatus, AutoloadFieldType, AutoloadReportStatus, ListingStatus +from avito.ads.enums import ( + AdsActionStatus, + AutoloadAvitoStatus, + AutoloadFieldType, + AutoloadItemStatus, + AutoloadItemStatusDetail, + AutoloadReportStatus, + ListingStatus, +) from avito.core.serialization import SerializableModel @@ -347,8 +355,10 @@ class AutoloadReportItem(SerializableModel): item_id: int | None avito_id: int | None - status: AutoloadReportStatus | None + status: AutoloadItemStatus | None title: str | None + status_detail: AutoloadItemStatusDetail | None = None + avito_status: AutoloadAvitoStatus | None = None @dataclass(slots=True, frozen=True) diff --git a/avito/auth/provider.py b/avito/auth/provider.py index 2d1edbb..bb05cac 100644 --- a/avito/auth/provider.py +++ b/avito/auth/provider.py @@ -18,6 +18,7 @@ ) from avito.auth.settings import AuthSettings from avito.core.exceptions import AuthenticationError, ConfigurationError +from avito.core.swagger import swagger_operation _UNSET = object() @@ -78,7 +79,7 @@ def get_autoteka_access_token(self) -> str: token = self._autoteka_access_token now = datetime.now(UTC) if token is None or token.is_expired(now): - token_response = self._get_autoteka_token_client().request_client_credentials_token( + token_response = self._get_autoteka_token_client().request_autoteka_client_credentials_token( ClientCredentialsRequest( client_id=self.settings.autoteka_client_id or self.settings.client_id or "", client_secret=self.settings.autoteka_client_secret @@ -147,9 +148,7 @@ def _update_tokens( self._refresh_token = refresh_token if autoteka_access_token is not _UNSET: self._autoteka_access_token = ( - autoteka_access_token - if isinstance(autoteka_access_token, AccessToken) - else None + autoteka_access_token if isinstance(autoteka_access_token, AccessToken) else None ) def _get_token_client(self) -> TokenClient: @@ -176,9 +175,7 @@ def _get_autoteka_token_client(self) -> TokenClient: ) autoteka_token_client = self.autoteka_token_client if autoteka_token_client is None: - raise ConfigurationError( - "Не удалось инициализировать OAuth token client для Автотеки." - ) + raise ConfigurationError("Не удалось инициализировать OAuth token client для Автотеки.") return autoteka_token_client def _require_client_id(self) -> str: @@ -196,6 +193,8 @@ def _require_client_secret(self) -> str: class TokenClient: """Служебный клиент для canonical OAuth token endpoint.""" + __swagger_domain__ = "auth" + settings: AuthSettings token_url: str | None = None client: httpx.Client | None = None @@ -206,6 +205,13 @@ def close(self) -> None: if self.client is not None: self.client.close() + @swagger_operation( + "POST", + "/token", + spec="Авторизация.json", + operation_id="getAccessToken", + method_args={"request": "body"}, + ) def request_client_credentials_token( self, request: ClientCredentialsRequest, @@ -221,6 +227,21 @@ def request_client_credentials_token( payload["scope"] = request.scope return self._request_token(payload) + @swagger_operation( + "POST", + "/token", + spec="Автотека.json", + operation_id="getAccessToken", + method_args={"request": "query.grant_type"}, + ) + def request_autoteka_client_credentials_token( + self, + request: ClientCredentialsRequest, + ) -> TokenResponse: + """Запрашивает access token по отдельному flow Автотеки.""" + + return self.request_client_credentials_token(request) + def request_refresh_token(self, request: RefreshTokenRequest) -> TokenResponse: """Запрашивает новый access token по flow `refresh_token`.""" @@ -297,6 +318,8 @@ def _extract_error_code(self, response: httpx.Response) -> str | None: class AlternateTokenClient: """Служебный клиент для альтернативного token endpoint из swagger.""" + __swagger_domain__ = "auth" + settings: AuthSettings client: httpx.Client | None = None @@ -306,6 +329,13 @@ def close(self) -> None: if self.client is not None: self.client.close() + @swagger_operation( + "POST", + "/token\u200e", + spec="Авторизация.json", + operation_id="getAccessTokenAuthorizationCode", + method_args={"request": "body"}, + ) def request_client_credentials_token( self, request: ClientCredentialsRequest, @@ -318,6 +348,13 @@ def request_client_credentials_token( client=self.client, ).request_client_credentials_token(request) + @swagger_operation( + "POST", + "/token\u200e\u200e", + spec="Авторизация.json", + operation_id="refreshAccessTokenAuthorizationCode", + method_args={"request": "body"}, + ) def request_refresh_token(self, request: RefreshTokenRequest) -> TokenResponse: """Обновляет токен через альтернативный canonical `/token`.""" diff --git a/avito/autoteka/domain.py b/avito/autoteka/domain.py index ac38247..04515e9 100644 --- a/avito/autoteka/domain.py +++ b/avito/autoteka/domain.py @@ -33,15 +33,27 @@ ) from avito.core import ValidationError from avito.core.domain import DomainObject +from avito.core.swagger import swagger_operation @dataclass(slots=True, frozen=True) class AutotekaVehicle(DomainObject): """Доменный объект превью, спецификаций, тизеров и каталога.""" + __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", + method_args={"brand_id": "body.fields_value_ids"}, + ) def resolve_catalog(self, *, brand_id: int) -> CatalogResolveResult: """Актуализирует параметры автокаталога. @@ -50,6 +62,13 @@ def resolve_catalog(self, *, brand_id: int) -> CatalogResolveResult: return CatalogClient(self.transport).resolve_catalog(brand_id=brand_id) + @swagger_operation( + "POST", + "/autoteka/v1/get-leads", + spec="Автотека.json", + operation_id="getLeads", + method_args={"limit": "body.limit"}, + ) def get_leads(self, *, limit: int) -> AutotekaLeadsResult: """Выполняет публичную операцию `AutotekaVehicle.get_leads` и возвращает типизированную SDK-модель. @@ -60,6 +79,13 @@ def get_leads(self, *, limit: int) -> AutotekaLeadsResult: return LeadsClient(self.transport).get_leads(limit=limit) + @swagger_operation( + "POST", + "/autoteka/v1/previews", + spec="Автотека.json", + operation_id="postPreviewByVin", + method_args={"vin": "body.vin"}, + ) def create_preview_by_vin( self, *, vin: str, idempotency_key: str | None = None ) -> AutotekaPreviewInfo: @@ -77,6 +103,12 @@ def create_preview_by_vin( idempotency_key=idempotency_key, ) + @swagger_operation( + "GET", + "/autoteka/v1/previews/{previewId}", + spec="Автотека.json", + operation_id="getPreview", + ) def get_preview(self, *, preview_id: int | str | None = None) -> AutotekaPreviewInfo: """Выполняет публичную операцию `AutotekaVehicle.get_preview` и возвращает типизированную SDK-модель. @@ -89,6 +121,13 @@ def get_preview(self, *, preview_id: int | str | None = None) -> AutotekaPreview preview_id=preview_id or self._require_vehicle_id("preview_id") ) + @swagger_operation( + "POST", + "/autoteka/v1/request-preview-by-external-item", + spec="Автотека.json", + operation_id="postPreviewByExternalItem", + method_args={"item_id": "body.item_id", "site": "body.site"}, + ) def create_preview_by_external_item( self, *, @@ -111,6 +150,13 @@ def create_preview_by_external_item( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/autoteka/v1/request-preview-by-item-id", + spec="Автотека.json", + operation_id="postPreviewByItemId", + method_args={"item_id": "body.item_id"}, + ) def create_preview_by_item_id( self, *, item_id: int, idempotency_key: str | None = None ) -> AutotekaPreviewInfo: @@ -128,6 +174,13 @@ def create_preview_by_item_id( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/autoteka/v1/request-preview-by-regnumber", + spec="Автотека.json", + operation_id="postPreviewByRegNumber", + method_args={"reg_number": "body.reg_number"}, + ) def create_preview_by_reg_number( self, *, reg_number: str, idempotency_key: str | None = None ) -> AutotekaPreviewInfo: @@ -145,6 +198,13 @@ def create_preview_by_reg_number( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/autoteka/v1/specifications/by-plate-number", + spec="Автотека.json", + operation_id="specificationByPlateNumber", + method_args={"plate_number": "body.plate_number"}, + ) def create_specification_by_plate_number( self, *, plate_number: str, idempotency_key: str | None = None ) -> AutotekaSpecificationInfo: @@ -162,6 +222,13 @@ def create_specification_by_plate_number( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/autoteka/v1/specifications/by-vehicle-id", + spec="Автотека.json", + operation_id="specificationByVehicleId", + method_args={"vehicle_id": "body.vehicle_id"}, + ) def create_specification_by_vehicle_id( self, *, vehicle_id: str, idempotency_key: str | None = None ) -> AutotekaSpecificationInfo: @@ -179,6 +246,12 @@ def create_specification_by_vehicle_id( idempotency_key=idempotency_key, ) + @swagger_operation( + "GET", + "/autoteka/v1/specifications/specification/{specificationID}", + spec="Автотека.json", + operation_id="specificationGetById", + ) def get_specification_by_id( self, *, @@ -195,6 +268,13 @@ def get_specification_by_id( specification_id=specification_id or self._require_vehicle_id("specification_id") ) + @swagger_operation( + "POST", + "/autoteka/v1/teasers", + spec="Автотека.json", + operation_id="postTeaser", + method_args={"vehicle_id": "body.vehicle_id"}, + ) def create_teaser( self, *, vehicle_id: str, idempotency_key: str | None = None ) -> AutotekaTeaserInfo: @@ -212,6 +292,12 @@ def create_teaser( idempotency_key=idempotency_key, ) + @swagger_operation( + "GET", + "/autoteka/v1/teasers/{teaser_id}", + spec="Автотека.json", + operation_id="getTeaser", + ) def get_teaser(self, *, teaser_id: int | str | None = None) -> AutotekaTeaserInfo: """Выполняет публичную операцию `AutotekaVehicle.get_teaser` и возвращает типизированную SDK-модель. @@ -234,9 +320,19 @@ def _require_vehicle_id(self, field_name: str) -> str: class AutotekaReport(DomainObject): """Доменный объект отчетов и пакетов Автотеки.""" + __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", + ) def get_active_package(self) -> AutotekaPackageInfo: """Выполняет публичную операцию `AutotekaReport.get_active_package` и возвращает типизированную SDK-модель. @@ -247,6 +343,13 @@ def get_active_package(self) -> AutotekaPackageInfo: return ReportClient(self.transport).get_active_package() + @swagger_operation( + "POST", + "/autoteka/v1/reports", + spec="Автотека.json", + operation_id="postReport", + method_args={"preview_id": "body.preview_id"}, + ) def create_report( self, *, preview_id: int, idempotency_key: str | None = None ) -> AutotekaReportInfo: @@ -264,6 +367,13 @@ def create_report( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/autoteka/v1/reports-by-vehicle-id", + spec="Автотека.json", + operation_id="postReportByVehicleId", + method_args={"vehicle_id": "body.vehicle_id"}, + ) def create_report_by_vehicle_id( self, *, vehicle_id: str, idempotency_key: str | None = None ) -> AutotekaReportInfo: @@ -281,6 +391,12 @@ def create_report_by_vehicle_id( idempotency_key=idempotency_key, ) + @swagger_operation( + "GET", + "/autoteka/v1/reports/list", + spec="Автотека.json", + operation_id="getReportList", + ) def list_reports(self) -> AutotekaReportsResult: """Получает список отчетов Автотеки. @@ -291,6 +407,12 @@ def list_reports(self) -> AutotekaReportsResult: return ReportClient(self.transport).list_reports() + @swagger_operation( + "GET", + "/autoteka/v1/reports/{report_id}", + spec="Автотека.json", + operation_id="getReport", + ) def get_report(self, *, report_id: int | str | None = None) -> AutotekaReportInfo: """Выполняет публичную операцию `AutotekaReport.get_report` и возвращает типизированную SDK-модель. @@ -303,6 +425,13 @@ def get_report(self, *, report_id: int | str | None = None) -> AutotekaReportInf report_id=report_id or self._require_report_id() ) + @swagger_operation( + "POST", + "/autoteka/v1/sync/create-by-regnumber", + spec="Автотека.json", + operation_id="postSyncCreateReportByRegNumber", + method_args={"reg_number": "body.reg_number"}, + ) def create_sync_report_by_reg_number( self, *, reg_number: str, idempotency_key: str | None = None ) -> AutotekaReportInfo: @@ -320,6 +449,13 @@ def create_sync_report_by_reg_number( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/autoteka/v1/sync/create-by-vin", + spec="Автотека.json", + operation_id="postSyncCreateReportByVin", + method_args={"vin": "body.vin"}, + ) def create_sync_report_by_vin( self, *, vin: str, idempotency_key: str | None = None ) -> AutotekaReportInfo: @@ -347,8 +483,18 @@ def _require_report_id(self) -> str: class AutotekaMonitoring(DomainObject): """Доменный объект мониторинга Автотеки.""" + __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", + method_args={"vehicles": "body.data"}, + ) def create_monitoring_bucket_add( self, *, vehicles: list[str], idempotency_key: str | None = None ) -> MonitoringBucketResult: @@ -366,6 +512,12 @@ def create_monitoring_bucket_add( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/autoteka/v1/monitoring/bucket/delete", + spec="Автотека.json", + operation_id="monitoringBucketDelete", + ) def delete_bucket(self, *, idempotency_key: str | None = None) -> MonitoringBucketResult: """Очищает bucket мониторинга. @@ -376,6 +528,13 @@ def delete_bucket(self, *, idempotency_key: str | None = None) -> MonitoringBuck return MonitoringClient(self.transport).delete_bucket(idempotency_key=idempotency_key) + @swagger_operation( + "POST", + "/autoteka/v1/monitoring/bucket/remove", + spec="Автотека.json", + operation_id="monitoringBucketRemove", + method_args={"vehicles": "body.data"}, + ) def remove_bucket( self, *, vehicles: list[str], idempotency_key: str | None = None ) -> MonitoringBucketResult: @@ -391,6 +550,12 @@ def remove_bucket( idempotency_key=idempotency_key, ) + @swagger_operation( + "GET", + "/autoteka/v1/monitoring/get-reg-actions", + spec="Автотека.json", + operation_id="monitoringGetRegActions", + ) def get_monitoring_reg_actions( self, *, @@ -410,9 +575,20 @@ def get_monitoring_reg_actions( class AutotekaScoring(DomainObject): """Доменный объект скоринга рисков.""" + __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", + method_args={"vehicle_id": "body.vehicle_id"}, + ) def create_scoring_by_vehicle_id( self, *, vehicle_id: str, idempotency_key: str | None = None ) -> AutotekaScoringInfo: @@ -430,6 +606,12 @@ def create_scoring_by_vehicle_id( idempotency_key=idempotency_key, ) + @swagger_operation( + "GET", + "/autoteka/v1/scoring/{scoring_id}", + spec="Автотека.json", + operation_id="scoringGetById", + ) def get_scoring_by_id(self, *, scoring_id: int | str | None = None) -> AutotekaScoringInfo: """Выполняет публичную операцию `AutotekaScoring.get_scoring_by_id` и возвращает типизированную SDK-модель. @@ -452,8 +634,18 @@ def _require_scoring_id(self) -> str: class AutotekaValuation(DomainObject): """Доменный объект оценки автомобиля.""" + __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", + method_args={"specification_id": "body.specification", "mileage": "body.mileage"}, + ) def get_valuation_by_specification( self, *, specification_id: int, mileage: int ) -> AutotekaValuationInfo: diff --git a/avito/autoteka/enums.py b/avito/autoteka/enums.py index 53fa7dc..cfdbf58 100644 --- a/avito/autoteka/enums.py +++ b/avito/autoteka/enums.py @@ -11,6 +11,10 @@ class AutotekaStatus(str, Enum): UNKNOWN = "__unknown__" PROCESSING = "processing" SUCCESS = "success" + NOT_FOUND = "notFound" + INCOMPLETE = "incomplete" + OK = "ok" + WARNING = "warning" __all__ = ("AutotekaStatus",) diff --git a/avito/client.py b/avito/client.py index c6ad99f..6afc226 100644 --- a/avito/client.py +++ b/avito/client.py @@ -38,7 +38,7 @@ TargetActionPricing, TrxPromotion, ) -from avito.promotion.enums import PromotionStatus +from avito.promotion.enums import PromotionOrderServiceStatus, PromotionOrderStatus from avito.ratings import RatingProfile, Review, ReviewAnswer from avito.realty import RealtyAnalyticsReport, RealtyBooking, RealtyListing, RealtyPricing from avito.summary import ( @@ -250,10 +250,11 @@ def listing_health( """Возвращает health-сводку объявлений без ручного сопоставления статистики.""" resolved_user_id = self._resolve_user_id(user_id) - listings = self.ad(user_id=resolved_user_id).list( + listing_collection = self.ad(user_id=resolved_user_id).list( limit=limit, page_size=page_size, - ).materialize() + ) + listings = 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] = {} @@ -314,10 +315,21 @@ def listing_health( ) 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, - total_listings=len(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), @@ -402,17 +414,26 @@ def promotion_summary(self, *, item_ids: list[int] | None = None) -> PromotionSu for item in orders.items if item.status in { - PromotionStatus.APPLIED, - PromotionStatus.AUTO, - PromotionStatus.CREATED, - PromotionStatus.MANUAL, - PromotionStatus.PARTIAL, - PromotionStatus.PROCESSED, + 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 is PromotionStatus.AVAILABLE + 1 + for item in service_items + if item.status + in { + PromotionOrderServiceStatus.ACTIVE, + PromotionOrderServiceStatus.AVAILABLE, + } ), ) diff --git a/avito/core/__init__.py b/avito/core/__init__.py index 266d548..6c30051 100644 --- a/avito/core/__init__.py +++ b/avito/core/__init__.py @@ -20,6 +20,7 @@ from avito.core.pagination import PaginatedList, Paginator from avito.core.retries import RetryDecision, RetryPolicy from avito.core.serialization import SerializableModel +from avito.core.swagger import SwaggerOperationBinding, swagger_operation from avito.core.transport import Transport from avito.core.types import ( ApiTimeouts, @@ -50,10 +51,12 @@ "RetryPolicy", "SerializableModel", "ServerError", + "SwaggerOperationBinding", "Transport", "TransportDebugInfo", "TransportError", "UnsupportedOperationError", "UpstreamApiError", "ValidationError", + "swagger_operation", ) diff --git a/avito/core/deprecation.py b/avito/core/deprecation.py index 7033015..b6099c7 100644 --- a/avito/core/deprecation.py +++ b/avito/core/deprecation.py @@ -2,6 +2,7 @@ import warnings from collections.abc import Callable +from dataclasses import dataclass from functools import wraps from typing import ParamSpec, TypeVar @@ -11,6 +12,16 @@ _WARNED_SYMBOLS: set[str] = set() +@dataclass(frozen=True, slots=True) +class DeprecatedSdkSymbol: + """Metadata for public SDK symbols that emit runtime deprecation warnings.""" + + symbol: str + replacement: str + removal_version: str + deprecated_since: str + + def warn_deprecated_once( *, symbol: str, @@ -39,6 +50,13 @@ def deprecated_method( removal_version: str, deprecated_since: str, ) -> Callable[[Callable[P, R]], Callable[P, R]]: + metadata = DeprecatedSdkSymbol( + symbol=symbol, + replacement=replacement, + removal_version=removal_version, + deprecated_since=deprecated_since, + ) + def decorate(method: Callable[P, R]) -> Callable[P, R]: @wraps(method) def wrapped(*args: P.args, **kwargs: P.kwargs) -> R: @@ -50,6 +68,7 @@ def wrapped(*args: P.args, **kwargs: P.kwargs) -> R: ) return method(*args, **kwargs) + wrapped.__sdk_deprecation__ = metadata # type: ignore[attr-defined] return wrapped return decorate diff --git a/avito/core/mapping.py b/avito/core/mapping.py index 7a55490..45439fb 100644 --- a/avito/core/mapping.py +++ b/avito/core/mapping.py @@ -17,6 +17,7 @@ def request_public_model[ModelT]( mapper: Callable[[object], ModelT], params: Mapping[str, object] | None = None, json_body: Mapping[str, object] | None = None, + headers: Mapping[str, str] | None = None, idempotency_key: str | None = None, ) -> ModelT: """Выполняет HTTP-запрос и маппит JSON в публичную модель SDK.""" @@ -28,6 +29,7 @@ def request_public_model[ModelT]( mapper=mapper, params=params, json_body=json_body, + headers=headers, idempotency_key=idempotency_key, ) diff --git a/avito/core/pagination.py b/avito/core/pagination.py index 0562e9c..3493222 100644 --- a/avito/core/pagination.py +++ b/avito/core/pagination.py @@ -30,6 +30,7 @@ def __init__( super().__init__() self._fetch_page = fetch_page 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 @@ -92,6 +93,18 @@ def loaded_count(self) -> int: return super().__len__() + @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: """Показывает, загружены ли все страницы коллекции.""" @@ -140,6 +153,8 @@ def _load_next_page(self) -> None: def _consume_page(self, page: JsonPage[ItemT]) -> None: super().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 diff --git a/avito/core/rate_limit.py b/avito/core/rate_limit.py new file mode 100644 index 0000000..54b9a22 --- /dev/null +++ b/avito/core/rate_limit.py @@ -0,0 +1,99 @@ +"""Локальный rate limiter transport-слоя.""" + +from __future__ import annotations + +import threading +import time +from collections.abc import Callable, Mapping + +from avito.core.retries import RetryPolicy + + +class RateLimiter: + """Token bucket для превентивного ограничения частоты запросов.""" + + def __init__( + self, + policy: RetryPolicy, + *, + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep, + ) -> None: + self._enabled = policy.rate_limit_enabled + self._rate = max(policy.rate_limit_requests_per_second, 0.0) + self._capacity = max(policy.rate_limit_burst, 0) + self._tokens = float(self._capacity) + self._updated_at = clock() + self._blocked_until = 0.0 + self._clock = clock + self._sleep = sleep + self._lock = threading.Lock() + + 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() + if delay <= 0.0: + return total_delay + self._sleep(delay) + total_delay += delay + + 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 + + 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 + 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 + + +__all__ = ("RateLimiter",) diff --git a/avito/core/retries.py b/avito/core/retries.py index c3a9333..1361c5b 100644 --- a/avito/core/retries.py +++ b/avito/core/retries.py @@ -33,6 +33,9 @@ class RetryPolicy: "retry_on_transport_error": ("AVITO_RETRY_RETRY_ON_TRANSPORT_ERROR",), "max_rate_limit_wait_seconds": ("AVITO_RETRY_MAX_RATE_LIMIT_WAIT_SECONDS",), "max_delay": ("AVITO_RETRY_MAX_DELAY",), + "rate_limit_enabled": ("AVITO_RATE_LIMIT_ENABLED",), + "rate_limit_requests_per_second": ("AVITO_RATE_LIMIT_REQUESTS_PER_SECOND",), + "rate_limit_burst": ("AVITO_RATE_LIMIT_BURST",), } max_attempts: int = 3 @@ -43,6 +46,9 @@ class RetryPolicy: retry_on_transport_error: bool = True max_rate_limit_wait_seconds: float = 30.0 max_delay: float = 30.0 + rate_limit_enabled: bool = False + rate_limit_requests_per_second: float = 8.0 + rate_limit_burst: int = 8 random_source: random_module.Random = field( default_factory=random_module.Random, repr=False, @@ -63,17 +69,29 @@ def from_env(cls, *, env_file: str | Path | None = ".env") -> RetryPolicy: retry_on_transport_error = defaults.retry_on_transport_error max_rate_limit_wait_seconds = defaults.max_rate_limit_wait_seconds max_delay = defaults.max_delay + rate_limit_enabled = defaults.rate_limit_enabled + rate_limit_requests_per_second = defaults.rate_limit_requests_per_second + rate_limit_burst = defaults.rate_limit_burst for field_name, value in resolved_values.items(): if field_name == "max_attempts": max_attempts = parse_env_int(value, field_name=field_name) - elif field_name in {"backoff_factor", "max_rate_limit_wait_seconds", "max_delay"}: + elif field_name == "rate_limit_burst": + rate_limit_burst = parse_env_int(value, field_name=field_name) + elif field_name in { + "backoff_factor", + "max_rate_limit_wait_seconds", + "max_delay", + "rate_limit_requests_per_second", + }: parsed_float = parse_env_float(value, field_name=field_name) if field_name == "backoff_factor": backoff_factor = parsed_float elif field_name == "max_rate_limit_wait_seconds": max_rate_limit_wait_seconds = parsed_float - else: + elif field_name == "max_delay": max_delay = parsed_float + else: + rate_limit_requests_per_second = parsed_float elif field_name == "retryable_methods": retryable_methods = parse_env_str_tuple(value, field_name=field_name) else: @@ -82,8 +100,10 @@ def from_env(cls, *, env_file: str | Path | None = ".env") -> RetryPolicy: retry_on_rate_limit = parsed_bool elif field_name == "retry_on_server_error": retry_on_server_error = parsed_bool - else: + elif field_name == "retry_on_transport_error": retry_on_transport_error = parsed_bool + else: + rate_limit_enabled = parsed_bool return cls( max_attempts=max_attempts, backoff_factor=backoff_factor, @@ -93,6 +113,9 @@ def from_env(cls, *, env_file: str | Path | None = ".env") -> RetryPolicy: retry_on_transport_error=retry_on_transport_error, max_rate_limit_wait_seconds=max_rate_limit_wait_seconds, max_delay=max_delay, + rate_limit_enabled=rate_limit_enabled, + rate_limit_requests_per_second=rate_limit_requests_per_second, + rate_limit_burst=rate_limit_burst, ) def is_retryable_method(self, method: str, *, explicit_retry: bool = False) -> bool: diff --git a/avito/core/swagger.py b/avito/core/swagger.py new file mode 100644 index 0000000..2f36b0f --- /dev/null +++ b/avito/core/swagger.py @@ -0,0 +1,103 @@ +"""Swagger operation binding metadata for public SDK methods.""" + +from __future__ import annotations + +import re +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import ParamSpec, TypeVar + +from avito.core.exceptions import ConfigurationError + +P = ParamSpec("P") +R = TypeVar("R") + +_PATH_PARAMETER_RE = re.compile(r"{([A-Za-z_][A-Za-z0-9_]*)}") +_EMPTY_MAPPING: Mapping[str, str] = MappingProxyType({}) + + +def _freeze_mapping(value: Mapping[str, str] | None) -> Mapping[str, str]: + if value is None: + return _EMPTY_MAPPING + return MappingProxyType(dict(value)) + + +def _normalize_method(method: str) -> str: + normalized = method.strip().upper() + if not normalized: + raise ConfigurationError("HTTP-метод Swagger binding не может быть пустым.") + return normalized + + +def _normalize_path(path: str) -> str: + normalized = path.strip() + if not normalized.startswith("/"): + raise ConfigurationError("Swagger path должен начинаться с `/`.") + if normalized != "/": + normalized = normalized.rstrip("/") + without_parameters = _PATH_PARAMETER_RE.sub("", normalized) + if "{" in without_parameters or "}" in without_parameters: + raise ConfigurationError("Swagger path должен использовать параметры в формате `{name}`.") + return normalized + + +@dataclass(frozen=True, slots=True) +class SwaggerOperationBinding: + """Связь публичного SDK-метода с одной Swagger/OpenAPI operation.""" + + method: str + path: str + spec: str | None = None + operation_id: str | None = None + factory: str | None = None + factory_args: Mapping[str, str] = field(default_factory=lambda: _EMPTY_MAPPING) + method_args: Mapping[str, str] = field(default_factory=lambda: _EMPTY_MAPPING) + deprecated: bool = False + legacy: bool = False + + def __post_init__(self) -> None: + 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)) + object.__setattr__(self, "method_args", _freeze_mapping(self.method_args)) + + +def swagger_operation( + method: str, + path: str, + *, + spec: str | None = None, + operation_id: str | None = None, + factory: str | None = None, + factory_args: Mapping[str, str] | None = None, + method_args: Mapping[str, str] | None = None, + deprecated: bool = False, + legacy: bool = False, +) -> Callable[[Callable[P, R]], Callable[P, R]]: + """Записывает Swagger binding metadata на публичный SDK-метод.""" + + binding = SwaggerOperationBinding( + method=method, + path=path, + spec=spec, + operation_id=operation_id, + factory=factory, + factory_args=_freeze_mapping(factory_args), + method_args=_freeze_mapping(method_args), + deprecated=deprecated, + legacy=legacy, + ) + + def decorate(func: Callable[P, R]) -> Callable[P, R]: + if hasattr(func, "__swagger_binding__") or hasattr(func, "__swagger_bindings__"): + raise ConfigurationError( + "Несколько Swagger binding-ов на одном SDK method запрещены." + ) + func.__swagger_binding__ = binding # type: ignore[attr-defined] + return func + + return decorate + + +__all__ = ("SwaggerOperationBinding", "swagger_operation") diff --git a/avito/core/swagger_discovery.py b/avito/core/swagger_discovery.py new file mode 100644 index 0000000..5c22608 --- /dev/null +++ b/avito/core/swagger_discovery.py @@ -0,0 +1,253 @@ +"""Discovery of Swagger operation bindings on public SDK domain methods.""" + +from __future__ import annotations + +import importlib +import importlib.util +import inspect +import pkgutil +from collections.abc import Mapping +from dataclasses import dataclass, field +from types import MappingProxyType, ModuleType +from typing import cast + +from avito.core.domain import DomainObject +from avito.core.swagger import SwaggerOperationBinding +from avito.core.swagger_registry import ( + SwaggerOperation, + SwaggerRegistry, + normalize_swagger_method, + normalize_swagger_path, +) + +_EMPTY_MAPPING: Mapping[str, str] = MappingProxyType({}) +_IGNORED_PACKAGES = frozenset({"auth", "core", "summary", "testing"}) +_NON_DOMAIN_BINDING_MODULES = ("avito.auth.provider",) + + +@dataclass(frozen=True, slots=True) +class DiscoveredSwaggerBinding: + """Effective binding metadata discovered on a public SDK domain method.""" + + module: str + class_name: str + method_name: str + domain: str | None + operation_key: str | None + spec: str | None + method: str + path: str + operation_id: str | None + factory: str | None + factory_args: Mapping[str, str] = field(default_factory=lambda: _EMPTY_MAPPING) + method_args: Mapping[str, str] = field(default_factory=lambda: _EMPTY_MAPPING) + deprecated: bool = False + legacy: bool = False + + @property + def sdk_method(self) -> str: + return f"{self.module}.{self.class_name}.{self.method_name}" + + +@dataclass(frozen=True, slots=True) +class SwaggerBindingDiscovery: + """Result of scanning SDK domain modules for Swagger operation bindings.""" + + bindings: tuple[DiscoveredSwaggerBinding, ...] + legacy_binding_methods: tuple[str, ...] = () + + @property + def canonical_map(self) -> Mapping[str, DiscoveredSwaggerBinding]: + mapped = { + binding.operation_key: binding + for binding in self.bindings + if binding.operation_key is not None + } + return MappingProxyType(mapped) + + +def discover_swagger_bindings( + *, + package_name: str = "avito", + registry: SwaggerRegistry | None = None, +) -> SwaggerBindingDiscovery: + """Discovers Swagger bindings without creating `AvitoClient` or doing HTTP work.""" + + package = importlib.import_module(package_name) + domain_modules = tuple(_iter_domain_modules(package, package_name)) + non_domain_modules = tuple( + importlib.import_module(name) for name in _NON_DOMAIN_BINDING_MODULES + ) + bindings: list[DiscoveredSwaggerBinding] = [] + legacy_binding_methods: list[str] = [] + for module in (*domain_modules, *non_domain_modules): + module_bindings, module_legacy_methods = _discover_module_bindings(module, registry) + bindings.extend(module_bindings) + legacy_binding_methods.extend(module_legacy_methods) + return SwaggerBindingDiscovery( + bindings=tuple(bindings), + legacy_binding_methods=tuple(sorted(set(legacy_binding_methods))), + ) + + +def _iter_domain_modules(package: ModuleType, package_name: str) -> tuple[ModuleType, ...]: + package_paths = getattr(package, "__path__", None) + if package_paths is None: + return () + + modules: list[ModuleType] = [] + 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)) + return tuple(modules) + + +def _discover_module_bindings( + module: ModuleType, + registry: SwaggerRegistry | None, +) -> tuple[tuple[DiscoveredSwaggerBinding, ...], tuple[str, ...]]: + bindings: list[DiscoveredSwaggerBinding] = [] + legacy_binding_methods: list[str] = [] + for _, cls in inspect.getmembers(module, inspect.isclass): + if cls.__module__ != module.__name__: + continue + if not _is_discoverable_binding_class(cls): + continue + if cls.__name__.startswith("_"): + continue + for method_name, func in inspect.getmembers(cls, inspect.isfunction): + if method_name.startswith("_"): + continue + sdk_method = f"{module.__name__}.{cls.__name__}.{method_name}" + if hasattr(func, "__swagger_bindings__"): + legacy_binding_methods.append(sdk_method) + raw_binding = _method_binding(func) + if raw_binding is not None: + bindings.append( + _build_effective_binding( + module=module, + cls=cls, + method_name=method_name, + binding=raw_binding, + registry=registry, + ) + ) + return tuple(bindings), tuple(legacy_binding_methods) + + +def _is_discoverable_binding_class(cls: type[object]) -> bool: + if issubclass(cls, DomainObject) and cls is not DomainObject: + return True + return _optional_string(getattr(cls, "__swagger_domain__", None)) is not None + + +def _method_binding(func: object) -> SwaggerOperationBinding | None: + raw_binding = getattr(func, "__swagger_binding__", None) + if isinstance(raw_binding, SwaggerOperationBinding): + return raw_binding + return None + + +def _build_effective_binding( + *, + module: ModuleType, + cls: type[object], + method_name: str, + binding: SwaggerOperationBinding, + registry: SwaggerRegistry | None, +) -> DiscoveredSwaggerBinding: + method = normalize_swagger_method(binding.method) + path = normalize_swagger_path(binding.path) + spec = binding.spec or _optional_string(getattr(cls, "__swagger_spec__", None)) + if spec is None and registry is not None: + spec = _resolve_spec(registry.operations, method=method, path=path) + operation_key = f"{spec} {method} {path}" if spec is not None else None + class_factory_args = _optional_mapping(getattr(cls, "__sdk_factory_args__", None)) + if registry is not None and operation_key is not None and not binding.factory_args: + operation = _operation_by_key(registry.operations, operation_key) + class_factory_args = _filter_factory_args_for_operation(class_factory_args, operation) + return DiscoveredSwaggerBinding( + module=module.__name__, + class_name=cls.__name__, + method_name=method_name, + domain=_optional_string(getattr(cls, "__swagger_domain__", None)), + operation_key=operation_key, + spec=spec, + method=method, + path=path, + operation_id=binding.operation_id, + factory=binding.factory or _optional_string(getattr(cls, "__sdk_factory__", None)), + factory_args=binding.factory_args or class_factory_args, + method_args=binding.method_args, + deprecated=binding.deprecated, + legacy=binding.legacy, + ) + + +def _operation_by_key( + operations: tuple[SwaggerOperation, ...], + operation_key: str, +) -> SwaggerOperation | None: + for operation in operations: + if operation.key == operation_key: + return operation + return None + + +def _filter_factory_args_for_operation( + factory_args: Mapping[str, str], + operation: SwaggerOperation | None, +) -> Mapping[str, str]: + if operation is None or not factory_args: + return factory_args + parameter_names = { + f"{parameter.location}.{parameter.name}" for parameter in operation.parameters + } + filtered = { + argument_name: expression + for argument_name, expression in factory_args.items() + if expression == "body" + or expression.startswith("body.") + or expression.startswith("constant.") + or expression in parameter_names + } + return MappingProxyType(filtered) + + +def _resolve_spec( + operations: tuple[SwaggerOperation, ...], + *, + method: str, + path: str, +) -> str | None: + matches = [ + operation.spec + for operation in operations + if operation.method == method and operation.path == path + ] + return matches[0] if len(matches) == 1 else None + + +def _optional_string(value: object) -> str | None: + return value if isinstance(value, str) and value else None + + +def _optional_mapping(value: object) -> Mapping[str, str]: + if value is None: + return _EMPTY_MAPPING + if not isinstance(value, Mapping): + return _EMPTY_MAPPING + return MappingProxyType( + {str(key): str(item) for key, item in cast(Mapping[object, object], value).items()} + ) + + +__all__ = ( + "DiscoveredSwaggerBinding", + "SwaggerBindingDiscovery", + "discover_swagger_bindings", +) diff --git a/avito/core/swagger_factory_map.py b/avito/core/swagger_factory_map.py new file mode 100644 index 0000000..51582f8 --- /dev/null +++ b/avito/core/swagger_factory_map.py @@ -0,0 +1,230 @@ +"""Working factory/domain mapping for Swagger binding rollout.""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable +from dataclasses import dataclass +from typing import cast, get_type_hints + +from avito.client import AvitoClient +from avito.core.domain import DomainObject + +_PACKAGE_SPEC_CANDIDATES: dict[str, tuple[str, ...]] = { + "accounts": ("Информацияопользователе.json", "ИерархияАккаунтов.json"), + "ads": ("Объявления.json", "Автозагрузка.json"), + "autoteka": ("Автотека.json",), + "cpa": ("CPAАвито.json", "CallTracking[КТ].json"), + "jobs": ("АвитоРабота.json",), + "messenger": ("Мессенджер.json", "Рассылкаскидокиспецпредложенийвмессенджере.json"), + "orders": ("Доставка.json", "Управлениезаказами.json", "Управлениеостатками.json"), + "promotion": ( + "Продвижение.json", + "TrxPromo.json", + "CPA-аукцион.json", + "Настройкаценыцелевогодействия.json", + "Автостратегия.json", + ), + "ratings": ("Рейтингииотзывы.json",), + "realty": ("Краткосрочнаяаренда.json", "Аналитикапонедвижимости.json"), + "tariffs": ("Тарифы.json",), +} +_CLASS_SPEC_CANDIDATES: dict[str, tuple[str, ...]] = { + "Account": ("Информацияопользователе.json",), + "AccountHierarchy": ("ИерархияАккаунтов.json",), + "Ad": ("Объявления.json",), + "AdPromotion": ("Объявления.json",), + "AdStats": ("Объявления.json",), + "Application": ("АвитоРабота.json",), + "AutoloadArchive": ("Автозагрузка.json",), + "AutoloadProfile": ("Автозагрузка.json",), + "AutoloadReport": ("Автозагрузка.json",), + "AutostrategyCampaign": ("Автостратегия.json",), + "AutotekaMonitoring": ("Автотека.json",), + "AutotekaReport": ("Автотека.json",), + "AutotekaScoring": ("Автотека.json",), + "AutotekaValuation": ("Автотека.json",), + "AutotekaVehicle": ("Автотека.json",), + "BbipPromotion": ("Продвижение.json",), + "CallTrackingCall": ("CallTracking[КТ].json",), + "Chat": ("Мессенджер.json",), + "ChatMedia": ("Мессенджер.json",), + "ChatMessage": ("Мессенджер.json",), + "ChatWebhook": ("Мессенджер.json",), + "CpaArchive": ("CPAАвито.json",), + "CpaAuction": ("CPA-аукцион.json",), + "CpaCall": ("CPAАвито.json",), + "CpaChat": ("CPAАвито.json",), + "CpaLead": ("CPAАвито.json",), + "DeliveryOrder": ("Доставка.json",), + "DeliveryTask": ("Доставка.json",), + "JobDictionary": ("АвитоРабота.json",), + "JobWebhook": ("АвитоРабота.json",), + "Order": ("Управлениезаказами.json",), + "OrderLabel": ("Доставка.json",), + "PromotionOrder": ("Продвижение.json",), + "RatingProfile": ("Рейтингииотзывы.json",), + "RealtyAnalyticsReport": ("Аналитикапонедвижимости.json",), + "RealtyBooking": ("Краткосрочнаяаренда.json",), + "RealtyListing": ("Краткосрочнаяаренда.json",), + "RealtyPricing": ("Краткосрочнаяаренда.json",), + "Resume": ("АвитоРабота.json",), + "Review": ("Рейтингииотзывы.json",), + "ReviewAnswer": ("Рейтингииотзывы.json",), + "SandboxDelivery": ("Доставка.json",), + "SpecialOfferCampaign": ("Рассылкаскидокиспецпредложенийвмессенджере.json",), + "Stock": ("Управлениеостатками.json",), + "TargetActionPricing": ("Настройкаценыцелевогодействия.json",), + "Tariff": ("Тарифы.json",), + "TrxPromotion": ("TrxPromo.json",), + "Vacancy": ("АвитоРабота.json",), +} + + +@dataclass(frozen=True, slots=True) +class FactoryDomainMapping: + """One AvitoClient factory mapped to a public domain class.""" + + factory: str + domain_class: str + module: str + package: str + factory_args: tuple[str, ...] + spec_candidates: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class ClientHelperMethod: + """Public AvitoClient method that must not receive a direct Swagger binding.""" + + method: str + return_type: str + reason: str + + +@dataclass(frozen=True, slots=True) +class FactoryDomainMappingReport: + """Non-authoritative helper report for domain binding rollout.""" + + factories: tuple[FactoryDomainMapping, ...] + helper_methods: tuple[ClientHelperMethod, ...] + + def to_dict(self) -> dict[str, object]: + """Return JSON-compatible report data.""" + + return { + "factories": [ + { + "factory": mapping.factory, + "domain_class": mapping.domain_class, + "module": mapping.module, + "package": mapping.package, + "factory_args": list(mapping.factory_args), + "spec_candidates": list(mapping.spec_candidates), + } + for mapping in self.factories + ], + "helper_methods": [ + { + "method": helper.method, + "return_type": helper.return_type, + "reason": helper.reason, + } + for helper in self.helper_methods + ], + } + + +def build_factory_domain_mapping_report() -> FactoryDomainMappingReport: + """Inspect AvitoClient factories without constructing AvitoClient.""" + + factories: list[FactoryDomainMapping] = [] + helper_methods: list[ClientHelperMethod] = [] + for method_name, method in inspect.getmembers(AvitoClient, inspect.isfunction): + if method_name.startswith("_"): + continue + return_type = _return_type(method) + if _is_domain_class(return_type): + factories.append( + _build_factory_mapping( + method_name, + cast(Callable[..., object], method), + cast(type[DomainObject], return_type), + ) + ) + else: + helper_methods.append( + ClientHelperMethod( + method=method_name, + return_type=_type_name(return_type), + reason="summary/helper method; no direct upstream Swagger operation", + ) + ) + + return FactoryDomainMappingReport( + factories=tuple(sorted(factories, key=lambda item: item.factory)), + helper_methods=tuple(sorted(helper_methods, key=lambda item: item.method)), + ) + + +def _return_type(method: object) -> object: + hints = get_type_hints(method) + return hints.get("return") + + +def _is_domain_class(value: object) -> bool: + return isinstance(value, type) and issubclass(value, DomainObject) and value is not DomainObject + + +def _build_factory_mapping( + method_name: str, + method: Callable[..., object], + return_type: type[DomainObject], +) -> FactoryDomainMapping: + package = _package_name(return_type) + return FactoryDomainMapping( + factory=method_name, + domain_class=return_type.__name__, + module=return_type.__module__, + package=package, + factory_args=tuple(_mappable_argument_names(inspect.signature(method))), + spec_candidates=_CLASS_SPEC_CANDIDATES.get( + return_type.__name__, + _PACKAGE_SPEC_CANDIDATES.get(package, ()), + ), + ) + + +def _package_name(cls: type[DomainObject]) -> str: + parts = cls.__module__.split(".") + return parts[1] if len(parts) > 1 else "" + + +def _mappable_argument_names(signature: inspect.Signature) -> tuple[str, ...]: + return tuple( + name + for name, parameter in signature.parameters.items() + if name != "self" + and parameter.kind + in { + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + } + ) + + +def _type_name(value: object) -> str: + if value is None: + return "None" + if isinstance(value, type): + return f"{value.__module__}.{value.__name__}" + return str(value) + + +__all__ = ( + "ClientHelperMethod", + "FactoryDomainMapping", + "FactoryDomainMappingReport", + "build_factory_domain_mapping_report", +) diff --git a/avito/core/swagger_linter.py b/avito/core/swagger_linter.py new file mode 100644 index 0000000..cadf488 --- /dev/null +++ b/avito/core/swagger_linter.py @@ -0,0 +1,626 @@ +"""Validation rules for Swagger operation bindings.""" + +from __future__ import annotations + +import importlib +import inspect +from collections import defaultdict +from collections.abc import Callable, Mapping, Sequence + +from avito.client import AvitoClient +from avito.core.deprecation import DeprecatedSdkSymbol +from avito.core.swagger_discovery import DiscoveredSwaggerBinding, SwaggerBindingDiscovery +from avito.core.swagger_registry import SwaggerOperation, SwaggerRegistry +from avito.core.swagger_report import SwaggerReportError + +_TEST_CONSTANTS = frozenset( + { + "account_id", + "action_id", + "call_id", + "campaign_id", + "chat_id", + "dictionary_id", + "item_id", + "limit", + "message_id", + "order_id", + "parcel_id", + "report_id", + "resume_id", + "scoring_id", + "tariff_id", + "task_id", + "user_id", + "url", + "vacancy_id", + "value", + "vehicle_id", + } +) + + +def lint_swagger_bindings( + registry: SwaggerRegistry, + discovery: SwaggerBindingDiscovery, + *, + strict: bool = False, +) -> tuple[SwaggerReportError, ...]: + """Validate discovered SDK bindings against the Swagger registry.""" + + operations_by_key = {operation.key: operation for operation in registry.operations} + spec_names = {spec.name for spec in registry.specs} + errors: list[SwaggerReportError] = [] + + errors.extend(_validate_legacy_stacked_binding_metadata(discovery)) + errors.extend(_validate_single_binding_per_sdk_method(discovery.bindings)) + errors.extend(_validate_duplicate_bindings(discovery.bindings)) + if strict: + errors.extend(_validate_complete_bindings(registry.operations, discovery.bindings)) + for binding in discovery.bindings: + operation = _resolve_bound_operation( + binding=binding, + operations_by_key=operations_by_key, + spec_names=spec_names, + errors=errors, + ) + sdk_method = _load_sdk_method(binding) + if operation is not None: + errors.extend(_validate_operation_metadata(binding, operation, sdk_method)) + errors.extend(_validate_binding_expressions(binding, operation)) + errors.extend(_validate_factory(binding)) + errors.extend(_validate_sdk_method_signature(binding, sdk_method)) + + return tuple(errors) + + +def _validate_legacy_stacked_binding_metadata( + discovery: SwaggerBindingDiscovery, +) -> tuple[SwaggerReportError, ...]: + return tuple( + SwaggerReportError( + code="SWAGGER_BINDING_METHOD_MULTIPLE", + message=f"{sdk_method}: legacy metadata `__swagger_bindings__` запрещена.", + operation_key=None, + sdk_method=sdk_method, + ) + for sdk_method in discovery.legacy_binding_methods + ) + + +def _validate_single_binding_per_sdk_method( + bindings: Sequence[DiscoveredSwaggerBinding], +) -> tuple[SwaggerReportError, ...]: + grouped: defaultdict[str, list[DiscoveredSwaggerBinding]] = defaultdict(list) + for binding in bindings: + grouped[binding.sdk_method].append(binding) + + errors: list[SwaggerReportError] = [] + for sdk_method, method_bindings in sorted(grouped.items()): + if len(method_bindings) < 2: + continue + operation_keys = ", ".join( + binding.operation_key or "" for binding in method_bindings + ) + for binding in method_bindings: + errors.append( + SwaggerReportError( + code="SWAGGER_BINDING_METHOD_MULTIPLE", + message=( + f"{sdk_method}: один SDK method связан с несколькими Swagger " + f"operations: {operation_keys}." + ), + operation_key=binding.operation_key, + sdk_method=sdk_method, + ) + ) + return tuple(errors) + + +def _validate_complete_bindings( + operations: Sequence[SwaggerOperation], + bindings: Sequence[DiscoveredSwaggerBinding], +) -> tuple[SwaggerReportError, ...]: + bound_operation_keys = { + binding.operation_key for binding in bindings if binding.operation_key is not None + } + errors: list[SwaggerReportError] = [] + for operation in operations: + if operation.key in bound_operation_keys: + continue + errors.append( + SwaggerReportError( + code="SWAGGER_BINDING_MISSING", + message=f"{operation.key}: для Swagger operation не найден SDK binding.", + operation_key=operation.key, + sdk_method=None, + ) + ) + return tuple(errors) + + +def _validate_duplicate_bindings( + bindings: Sequence[DiscoveredSwaggerBinding], +) -> tuple[SwaggerReportError, ...]: + grouped: defaultdict[str, list[DiscoveredSwaggerBinding]] = defaultdict(list) + for binding in bindings: + if binding.operation_key is not None: + grouped[binding.operation_key].append(binding) + + errors: list[SwaggerReportError] = [] + for operation_key, operation_bindings in sorted(grouped.items()): + if len(operation_bindings) < 2: + continue + methods = ", ".join(binding.sdk_method for binding in operation_bindings) + for binding in operation_bindings: + errors.append( + SwaggerReportError( + code="SWAGGER_BINDING_DUPLICATE", + message=( + f"{operation_key}: несколько SDK binding-ов указывают на одну " + f"Swagger operation: {methods}." + ), + operation_key=operation_key, + sdk_method=binding.sdk_method, + ) + ) + return tuple(errors) + + +def _resolve_bound_operation( + *, + binding: DiscoveredSwaggerBinding, + operations_by_key: Mapping[str, SwaggerOperation], + spec_names: set[str], + errors: list[SwaggerReportError], +) -> SwaggerOperation | None: + if binding.operation_key is None: + errors.append( + SwaggerReportError( + code="SWAGGER_BINDING_AMBIGUOUS", + message=( + f"{binding.sdk_method}: Swagger operation нельзя определить однозначно. " + "Укажите `spec` в binding или class-level metadata." + ), + operation_key=None, + sdk_method=binding.sdk_method, + ) + ) + return None + + if binding.spec not in spec_names: + errors.append( + SwaggerReportError( + code="SWAGGER_BINDING_SPEC_NOT_FOUND", + message=f"{binding.sdk_method}: Swagger spec не найден: {binding.spec}.", + operation_key=binding.operation_key, + sdk_method=binding.sdk_method, + ) + ) + return None + + operation = operations_by_key.get(binding.operation_key) + if operation is None: + errors.append( + SwaggerReportError( + code="SWAGGER_BINDING_NOT_FOUND", + message=( + f"{binding.sdk_method}: Swagger operation не найдена: {binding.operation_key}." + ), + operation_key=binding.operation_key, + sdk_method=binding.sdk_method, + ) + ) + return operation + + +def _validate_operation_metadata( + binding: DiscoveredSwaggerBinding, + operation: SwaggerOperation, + sdk_method: Callable[..., object] | None, +) -> tuple[SwaggerReportError, ...]: + errors: list[SwaggerReportError] = [] + if binding.operation_id is not None and binding.operation_id != operation.operation_id: + errors.append( + SwaggerReportError( + code="SWAGGER_BINDING_OPERATION_ID_MISMATCH", + message=( + f"{binding.sdk_method}: operation_id `{binding.operation_id}` " + f"не совпадает со Swagger operation_id `{operation.operation_id}`." + ), + operation_key=operation.key, + sdk_method=binding.sdk_method, + ) + ) + if binding.deprecated != operation.deprecated: + errors.append( + SwaggerReportError( + code="SWAGGER_BINDING_DEPRECATED_MISMATCH", + message=( + f"{binding.sdk_method}: deprecated={binding.deprecated} не совпадает " + f"со Swagger deprecated={operation.deprecated}." + ), + operation_key=operation.key, + sdk_method=binding.sdk_method, + ) + ) + if binding.legacy and not operation.deprecated: + errors.append( + SwaggerReportError( + code="SWAGGER_BINDING_LEGACY_MISMATCH", + message=( + f"{binding.sdk_method}: legacy=True разрешён только для deprecated " + "Swagger operation или явного исключения." + ), + operation_key=operation.key, + sdk_method=binding.sdk_method, + ) + ) + if operation.deprecated and not binding.legacy: + errors.append( + SwaggerReportError( + code="SWAGGER_BINDING_LEGACY_REQUIRED", + message=( + f"{binding.sdk_method}: deprecated Swagger operation должна иметь " + "legacy=True в binding." + ), + operation_key=operation.key, + sdk_method=binding.sdk_method, + ) + ) + if operation.deprecated and not _has_runtime_deprecation(sdk_method): + errors.append( + SwaggerReportError( + code="SWAGGER_BINDING_DEPRECATION_WARNING_MISSING", + message=( + f"{binding.sdk_method}: deprecated public method должен иметь runtime " + "DeprecationWarning через `deprecated_method`." + ), + operation_key=operation.key, + sdk_method=binding.sdk_method, + ) + ) + return tuple(errors) + + +def _validate_factory(binding: DiscoveredSwaggerBinding) -> tuple[SwaggerReportError, ...]: + if binding.domain == "auth" and binding.factory is None: + return () + if binding.factory is None: + return ( + SwaggerReportError( + code="SWAGGER_BINDING_FACTORY_MISSING", + message=f"{binding.sdk_method}: binding должен указывать AvitoClient factory.", + operation_key=binding.operation_key, + sdk_method=binding.sdk_method, + ), + ) + + factory = getattr(AvitoClient, binding.factory, None) + if not callable(factory): + return ( + SwaggerReportError( + code="SWAGGER_BINDING_FACTORY_NOT_FOUND", + message=f"{binding.sdk_method}: AvitoClient factory не найден: {binding.factory}.", + operation_key=binding.operation_key, + sdk_method=binding.sdk_method, + ), + ) + + return _validate_signature_mapping( + binding=binding, + signature=inspect.signature(factory), + mapping=binding.factory_args, + subject=f"factory `{binding.factory}`", + code_prefix="SWAGGER_BINDING_FACTORY", + ) + + +def _validate_sdk_method_signature( + binding: DiscoveredSwaggerBinding, + sdk_method: Callable[..., object] | None, +) -> tuple[SwaggerReportError, ...]: + if sdk_method is None: + return ( + SwaggerReportError( + code="SWAGGER_BINDING_SDK_METHOD_NOT_FOUND", + message=f"{binding.sdk_method}: SDK method не найден при signature validation.", + operation_key=binding.operation_key, + sdk_method=binding.sdk_method, + ), + ) + return _validate_signature_mapping( + binding=binding, + signature=inspect.signature(sdk_method), + mapping=binding.method_args, + subject="SDK method", + code_prefix="SWAGGER_BINDING_METHOD", + ) + + +def _validate_binding_expressions( + binding: DiscoveredSwaggerBinding, + operation: SwaggerOperation, +) -> tuple[SwaggerReportError, ...]: + errors: list[SwaggerReportError] = [] + errors.extend( + _validate_expression_mapping( + binding=binding, + operation=operation, + mapping=binding.factory_args, + subject="factory_args", + ) + ) + errors.extend( + _validate_expression_mapping( + binding=binding, + operation=operation, + mapping=binding.method_args, + subject="method_args", + ) + ) + return tuple(errors) + + +def _validate_expression_mapping( + *, + binding: DiscoveredSwaggerBinding, + operation: SwaggerOperation, + mapping: Mapping[str, str], + subject: str, +) -> tuple[SwaggerReportError, ...]: + errors: list[SwaggerReportError] = [] + for argument_name, expression in sorted(mapping.items()): + errors.extend( + _validate_expression( + binding=binding, + operation=operation, + subject=subject, + argument_name=argument_name, + expression=expression, + ) + ) + return tuple(errors) + + +def _validate_expression( + *, + binding: DiscoveredSwaggerBinding, + operation: SwaggerOperation, + subject: str, + argument_name: str, + expression: str, +) -> tuple[SwaggerReportError, ...]: + if expression == "body": + if operation.request_body is None: + return ( + _expression_error( + binding=binding, + code="SWAGGER_BINDING_BODY_MISSING", + message=( + f"{binding.sdk_method}: {subject}.{argument_name} указывает на " + "`body`, но Swagger operation не содержит requestBody." + ), + ), + ) + return () + + prefix, separator, field_name = expression.partition(".") + if not separator or not field_name: + return ( + _expression_error( + binding=binding, + code="SWAGGER_BINDING_EXPRESSION_INVALID", + message=( + f"{binding.sdk_method}: {subject}.{argument_name} содержит " + f"некорректное expression `{expression}`." + ), + ), + ) + + if prefix == "path": + return _validate_parameter_expression( + binding=binding, + operation=operation, + subject=subject, + argument_name=argument_name, + expression=expression, + field_name=field_name, + location="path", + ) + if prefix == "query": + return _validate_parameter_expression( + binding=binding, + operation=operation, + subject=subject, + argument_name=argument_name, + expression=expression, + field_name=field_name, + location="query", + ) + if prefix == "header": + return _validate_parameter_expression( + binding=binding, + operation=operation, + subject=subject, + argument_name=argument_name, + expression=expression, + field_name=field_name, + location="header", + ) + if prefix == "body": + request_body = operation.request_body + if request_body is None: + return ( + _expression_error( + binding=binding, + code="SWAGGER_BINDING_BODY_MISSING", + message=( + f"{binding.sdk_method}: {subject}.{argument_name} указывает на " + f"`{expression}`, но Swagger operation не содержит requestBody." + ), + ), + ) + if not request_body.schema_extracted: + return ( + _expression_error( + binding=binding, + code="SWAGGER_BINDING_BODY_SCHEMA_UNSUPPORTED", + message=( + f"{binding.sdk_method}: {subject}.{argument_name} указывает на " + f"`{expression}`, но requestBody schema не поддержана для " + "field-level validation." + ), + ), + ) + if field_name not in request_body.field_names: + return ( + _expression_error( + binding=binding, + code="SWAGGER_BINDING_BODY_FIELD_NOT_FOUND", + message=( + f"{binding.sdk_method}: {subject}.{argument_name} указывает на " + f"`{expression}`, но Swagger requestBody не содержит поле " + f"`{field_name}`." + ), + ), + ) + return () + if prefix == "constant": + if field_name not in _TEST_CONSTANTS: + return ( + _expression_error( + binding=binding, + code="SWAGGER_BINDING_CONSTANT_NOT_FOUND", + message=( + f"{binding.sdk_method}: {subject}.{argument_name} указывает на " + f"неизвестную test constant `{field_name}`." + ), + ), + ) + return () + return ( + _expression_error( + binding=binding, + code="SWAGGER_BINDING_EXPRESSION_UNKNOWN", + message=( + f"{binding.sdk_method}: {subject}.{argument_name} использует " + f"запрещённый expression prefix `{prefix}`." + ), + ), + ) + + +def _validate_parameter_expression( + *, + binding: DiscoveredSwaggerBinding, + operation: SwaggerOperation, + subject: str, + argument_name: str, + expression: str, + field_name: str, + location: str, +) -> tuple[SwaggerReportError, ...]: + parameter_names = { + parameter.name for parameter in operation.parameters if parameter.location == location + } + if field_name in parameter_names: + return () + return ( + _expression_error( + binding=binding, + code=f"SWAGGER_BINDING_{location.upper()}_PARAMETER_NOT_FOUND", + message=( + f"{binding.sdk_method}: {subject}.{argument_name} указывает на " + f"`{expression}`, но Swagger operation не содержит {location}-parameter " + f"`{field_name}`." + ), + ), + ) + + +def _expression_error( + *, + binding: DiscoveredSwaggerBinding, + code: str, + message: str, +) -> SwaggerReportError: + return SwaggerReportError( + code=code, + message=message, + operation_key=binding.operation_key, + sdk_method=binding.sdk_method, + ) + + +def _load_sdk_method(binding: DiscoveredSwaggerBinding) -> Callable[..., object] | None: + module = importlib.import_module(binding.module) + cls = getattr(module, binding.class_name, None) + method = getattr(cls, binding.method_name, None) + return method if callable(method) else None + + +def _has_runtime_deprecation(method: Callable[..., object] | None) -> bool: + metadata = getattr(method, "__sdk_deprecation__", None) + return isinstance(metadata, DeprecatedSdkSymbol) + + +def _validate_signature_mapping( + *, + binding: DiscoveredSwaggerBinding, + signature: inspect.Signature, + mapping: Mapping[str, str], + subject: str, + code_prefix: str, +) -> tuple[SwaggerReportError, ...]: + parameters = _mappable_parameters(signature) + parameter_names = set(parameters) + errors: list[SwaggerReportError] = [] + + for argument_name in sorted(set(mapping) - parameter_names): + errors.append( + SwaggerReportError( + code=f"{code_prefix}_ARG_UNKNOWN", + message=( + f"{binding.sdk_method}: {subject} не содержит параметр `{argument_name}`." + ), + operation_key=binding.operation_key, + sdk_method=binding.sdk_method, + ) + ) + + for argument_name, parameter in parameters.items(): + if parameter.default is not inspect.Parameter.empty: + continue + if argument_name not in mapping: + errors.append( + SwaggerReportError( + code=f"{code_prefix}_ARG_REQUIRED", + message=( + f"{binding.sdk_method}: обязательный параметр {subject} " + f"`{argument_name}` не покрыт mapping-ом." + ), + operation_key=binding.operation_key, + sdk_method=binding.sdk_method, + ) + ) + return tuple(errors) + + +def _mappable_parameters( + signature: inspect.Signature, +) -> Mapping[str, inspect.Parameter]: + return { + name: parameter + for name, parameter in signature.parameters.items() + if name != "self" + and parameter.kind + in { + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + } + } + + +__all__ = ("lint_swagger_bindings",) diff --git a/avito/core/swagger_registry.py b/avito/core/swagger_registry.py new file mode 100644 index 0000000..e94d884 --- /dev/null +++ b/avito/core/swagger_registry.py @@ -0,0 +1,573 @@ +"""Swagger/OpenAPI registry used by binding linting tools.""" + +from __future__ import annotations + +import json +import re +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import cast + +HTTP_METHODS = frozenset({"delete", "get", "head", "options", "patch", "post", "put", "trace"}) +DEFAULT_SWAGGER_API_DIR = Path("docs/avito/api") + +_PATH_PARAMETER_RE = re.compile(r"{([A-Za-z_][A-Za-z0-9_]*)}") +_FIRST_CAP_RE = re.compile("(.)([A-Z][a-z]+)") +_ALL_CAP_RE = re.compile("([a-z0-9])([A-Z])") + +JsonObject = dict[str, object] + + +class SwaggerRegistryError(Exception): + """Ошибка чтения или валидации локального Swagger corpus.""" + + +@dataclass(frozen=True, slots=True) +class SwaggerValidationError: + """Нефатальная ошибка валидации Swagger operation, найденная при разборе specs.""" + + code: str + message: str + operation_key: str | None = None + + +@dataclass(frozen=True, slots=True) +class SwaggerParameter: + """Параметр Swagger operation после разрешения локальных `$ref`.""" + + name: str + location: str + required: bool + + +@dataclass(frozen=True, slots=True) +class SwaggerRequestBody: + """Metadata request body для validation binding expressions.""" + + required: bool + content_types: tuple[str, ...] + field_names: tuple[str, ...] + schema_extracted: bool + + +@dataclass(frozen=True, slots=True) +class SwaggerResponse: + """Минимальная metadata Swagger response для contract tests.""" + + status_code: str + content_types: tuple[str, ...] + + @property + def is_success(self) -> bool: + return self.status_code.isdigit() and 200 <= int(self.status_code) < 300 + + @property + def is_error(self) -> bool: + return self.status_code == "default" or ( + self.status_code.isdigit() and int(self.status_code) >= 400 + ) + + +@dataclass(frozen=True, slots=True) +class SwaggerOperation: + """Одна Swagger/OpenAPI operation с normalized identity.""" + + spec: str + method: str + path: str + operation_id: str | None + deprecated: bool + parameters: tuple[SwaggerParameter, ...] + request_body: SwaggerRequestBody | None + responses: tuple[SwaggerResponse, ...] + + @property + def key(self) -> str: + return f"{self.spec} {self.method} {self.path}" + + @property + def path_parameters(self) -> tuple[SwaggerParameter, ...]: + return tuple(parameter for parameter in self.parameters if parameter.location == "path") + + @property + def query_parameters(self) -> tuple[SwaggerParameter, ...]: + return tuple(parameter for parameter in self.parameters if parameter.location == "query") + + @property + def header_parameters(self) -> tuple[SwaggerParameter, ...]: + return tuple(parameter for parameter in self.parameters if parameter.location == "header") + + @property + def success_responses(self) -> tuple[SwaggerResponse, ...]: + return tuple(response for response in self.responses if response.is_success) + + @property + def error_responses(self) -> tuple[SwaggerResponse, ...]: + return tuple(response for response in self.responses if response.is_error) + + +@dataclass(frozen=True, slots=True) +class SwaggerSpec: + """Один Swagger/OpenAPI файл и извлечённые из него операции.""" + + name: str + path: Path + operations: tuple[SwaggerOperation, ...] + + +@dataclass(frozen=True, slots=True) +class SwaggerRegistry: + """Полный локальный Swagger corpus.""" + + specs: tuple[SwaggerSpec, ...] + errors: tuple[SwaggerValidationError, ...] = () + + @property + def operations(self) -> tuple[SwaggerOperation, ...]: + return tuple(operation for spec in self.specs for operation in spec.operations) + + @property + def deprecated_operations(self) -> tuple[SwaggerOperation, ...]: + return tuple(operation for operation in self.operations if operation.deprecated) + + +def load_swagger_registry( + api_dir: Path = DEFAULT_SWAGGER_API_DIR, + *, + strict: bool = False, +) -> SwaggerRegistry: + """Загружает и валидирует все Swagger/OpenAPI specs из каталога.""" + + spec_paths = tuple(sorted(api_dir.glob("*.json"))) + if not spec_paths: + raise SwaggerRegistryError(f"В каталоге {api_dir} не найдены Swagger JSON files.") + + errors: list[SwaggerValidationError] = [] + specs = tuple(_load_spec(path, errors) for path in spec_paths) + _validate_unique_operation_keys(specs, errors) + registry = SwaggerRegistry(specs=specs, errors=tuple(errors)) + if strict and registry.errors: + messages = "; ".join(error.message for error in registry.errors) + raise SwaggerRegistryError(messages) + return registry + + +def normalize_swagger_method(method: str) -> str: + """Возвращает normalized HTTP method для operation identity.""" + + normalized = method.strip().upper() + if not normalized: + raise SwaggerRegistryError("HTTP-метод Swagger operation не может быть пустым.") + return normalized + + +def normalize_swagger_path(path: str) -> str: + """Возвращает normalized Swagger path для operation identity.""" + + normalized = path.strip() + if not normalized.startswith("/"): + raise SwaggerRegistryError(f"Swagger path должен начинаться с `/`: {path}") + if normalized != "/": + normalized = normalized.rstrip("/") + without_parameters = _PATH_PARAMETER_RE.sub("", normalized) + if "{" in without_parameters or "}" in without_parameters: + raise SwaggerRegistryError( + f"Swagger path должен использовать параметры в формате `{{name}}`: {path}" + ) + return normalized + + +def _load_spec(path: Path, errors: list[SwaggerValidationError]) -> SwaggerSpec: + try: + # `json.loads()` возвращает object на границе JSON-декодирования. + raw = cast(object, json.loads(path.read_text(encoding="utf-8"))) + except json.JSONDecodeError as exc: + raise SwaggerRegistryError(f"Файл {path} содержит некорректный JSON: {exc}") from exc + spec = _require_mapping(raw, f"{path}") + paths = _require_mapping(spec.get("paths"), f"{path}: поле paths") + + operations: list[SwaggerOperation] = [] + for raw_path, raw_path_item in sorted(paths.items()): + if not isinstance(raw_path, str): + raise SwaggerRegistryError(f"{path}: ключ paths должен быть строкой.") + path_item = _require_mapping(raw_path_item, f"{path}: path item {raw_path}") + path_parameters = _extract_parameters( + spec=spec, + parameters=_optional_sequence(path_item.get("parameters"), f"{path}: {raw_path}.parameters"), + source=f"{path}: {raw_path}.parameters", + ) + for raw_method, raw_operation in sorted(path_item.items()): + if not isinstance(raw_method, str) or raw_method.lower() not in HTTP_METHODS: + continue + operation = _require_mapping(raw_operation, f"{path}: {raw_path}.{raw_method}") + operation_parameters = _extract_parameters( + spec=spec, + parameters=_optional_sequence( + operation.get("parameters"), + f"{path}: {raw_path}.{raw_method}.parameters", + ), + source=f"{path}: {raw_path}.{raw_method}.parameters", + ) + normalized_path = normalize_swagger_path(raw_path) + parameters = (*path_parameters, *operation_parameters) + normalized_path = _normalize_path_parameter_aliases( + path=normalized_path, + parameters=parameters, + ) + _validate_path_parameters( + spec_name=path.name, + method=normalize_swagger_method(raw_method), + path=normalized_path, + parameters=parameters, + errors=errors, + ) + operations.append( + SwaggerOperation( + spec=path.name, + method=normalize_swagger_method(raw_method), + path=normalized_path, + operation_id=_optional_string(operation.get("operationId")), + deprecated=operation.get("deprecated") is True, + parameters=parameters, + request_body=_extract_request_body( + spec=spec, + raw_request_body=operation.get("requestBody"), + ), + responses=_extract_responses(operation.get("responses")), + ) + ) + + return SwaggerSpec(name=path.name, path=path, operations=tuple(operations)) + + +def _extract_parameters( + *, + spec: Mapping[str, object], + parameters: Iterable[object], + source: str, +) -> tuple[SwaggerParameter, ...]: + extracted: list[SwaggerParameter] = [] + for index, raw_parameter in enumerate(parameters): + parameter = _resolve_ref(spec, raw_parameter, f"{source}[{index}]") + name = _required_string(parameter.get("name"), f"{source}[{index}].name") + location = _required_string(parameter.get("in"), f"{source}[{index}].in") + extracted.append( + SwaggerParameter( + name=name, + location=location, + required=parameter.get("required") is True, + ) + ) + return tuple(extracted) + + +def _extract_request_body( + *, + spec: Mapping[str, object], + raw_request_body: object, +) -> SwaggerRequestBody | None: + if raw_request_body is None: + return None + request_body = _resolve_component_ref( + spec=spec, + raw_value=raw_request_body, + source="requestBody", + component_name="requestBodies", + ) + content = _require_mapping(request_body.get("content"), "requestBody.content") + field_names: set[str] = set() + schema_extracted = True + schema_count = 0 + for content_type, raw_media_type in content.items(): + media_type = _require_mapping( + raw_media_type, + f"requestBody.content.{content_type}", + ) + raw_schema = media_type.get("schema") + if raw_schema is None: + schema_extracted = False + continue + schema_count += 1 + extracted = _extract_schema_field_names( + spec=spec, + raw_schema=raw_schema, + source=f"requestBody.content.{content_type}.schema", + seen_refs=frozenset(), + ) + if extracted is None: + schema_extracted = False + continue + field_names.update(extracted) + if schema_count == 0: + schema_extracted = False + return SwaggerRequestBody( + required=request_body.get("required") is True, + content_types=tuple(sorted(str(content_type) for content_type in content)), + field_names=tuple(sorted(field_names)), + schema_extracted=schema_extracted, + ) + + +def _extract_responses(raw_responses: object) -> tuple[SwaggerResponse, ...]: + responses = _require_mapping(raw_responses, "responses") + extracted: list[SwaggerResponse] = [] + for raw_status_code, raw_response in sorted(responses.items()): + if not isinstance(raw_status_code, str): + raise SwaggerRegistryError("responses должен использовать строковые status codes.") + response = _require_mapping(raw_response, f"responses.{raw_status_code}") + content = response.get("content") + content_types: tuple[str, ...] = () + if isinstance(content, Mapping): + content_types = tuple(sorted(str(content_type) for content_type in content)) + extracted.append( + SwaggerResponse( + status_code=raw_status_code, + content_types=content_types, + ) + ) + return tuple(extracted) + + +def _validate_path_parameters( + *, + spec_name: str, + method: str, + path: str, + parameters: tuple[SwaggerParameter, ...], + errors: list[SwaggerValidationError], +) -> None: + path_parameter_names = set(_PATH_PARAMETER_RE.findall(path)) + described_path_parameter_names = { + parameter.name for parameter in parameters if parameter.location == "path" + } + if path_parameter_names != described_path_parameter_names: + missing = sorted(path_parameter_names - described_path_parameter_names) + extra = sorted(described_path_parameter_names - path_parameter_names) + operation_key = f"{spec_name} {method} {path}" + errors.append( + SwaggerValidationError( + code="SWAGGER_PATH_PARAMETER_MISMATCH", + message=( + f"{operation_key}: path parameters не совпадают с URL " + f"(missing={missing}, extra={extra})." + ), + operation_key=operation_key, + ) + ) + + +def _normalize_path_parameter_aliases( + *, + path: str, + parameters: tuple[SwaggerParameter, ...], +) -> str: + """Normalizes path placeholders to described path parameter names.""" + + described_path_parameters = tuple( + parameter for parameter in parameters if parameter.location == "path" + ) + if not described_path_parameters: + return path + + described_by_token: dict[str, str | None] = {} + for parameter in described_path_parameters: + token = _parameter_name_token(parameter.name) + if token in described_by_token: + described_by_token[token] = None + else: + described_by_token[token] = parameter.name + + def replace(match: re.Match[str]) -> str: + raw_name = match.group(1) + described_name = described_by_token.get(_parameter_name_token(raw_name)) + if described_name is None: + return match.group(0) + return f"{{{described_name}}}" + + return _PATH_PARAMETER_RE.sub(replace, path) + + +def _parameter_name_token(name: str) -> str: + return name.replace("_", "").lower() + + +def _validate_unique_operation_keys( + specs: tuple[SwaggerSpec, ...], + errors: list[SwaggerValidationError], +) -> None: + seen: set[str] = set() + duplicates: list[str] = [] + for spec in specs: + for operation in spec.operations: + if operation.key in seen: + duplicates.append(operation.key) + seen.add(operation.key) + if duplicates: + for operation_key in sorted(duplicates): + errors.append( + SwaggerValidationError( + code="SWAGGER_DUPLICATE_OPERATION_KEY", + message=f"Найден duplicate Swagger operation key: {operation_key}", + operation_key=operation_key, + ) + ) + + +def _resolve_ref(spec: Mapping[str, object], raw_value: object, source: str) -> Mapping[str, object]: + return _resolve_component_ref( + spec=spec, + raw_value=raw_value, + source=source, + component_name="parameters", + ) + + +def _resolve_component_ref( + *, + spec: Mapping[str, object], + raw_value: object, + source: str, + component_name: str, +) -> Mapping[str, object]: + value = _require_mapping(raw_value, source) + raw_ref = value.get("$ref") + if raw_ref is None: + return value + ref = _required_string(raw_ref, f"{source}.$ref") + prefix = f"#/components/{component_name}/" + if not ref.startswith(prefix): + raise SwaggerRegistryError( + f"{source}: поддерживаются только локальные refs components/{component_name}." + ) + object_name = ref.removeprefix(prefix) + components = _require_mapping(spec.get("components"), "components") + component = _require_mapping(components.get(component_name), f"components.{component_name}") + return _require_mapping(component.get(object_name), ref) + + +def _extract_schema_field_names( + *, + spec: Mapping[str, object], + raw_schema: object, + source: str, + seen_refs: frozenset[str], +) -> set[str] | None: + schema = _require_mapping(raw_schema, source) + raw_ref = schema.get("$ref") + if raw_ref is not None: + ref = _required_string(raw_ref, f"{source}.$ref") + if ref in seen_refs: + return None + prefix = "#/components/schemas/" + if not ref.startswith(prefix): + return None + schema_name = ref.removeprefix(prefix) + components = _require_mapping(spec.get("components"), "components") + schemas = _require_mapping(components.get("schemas"), "components.schemas") + resolved = _require_mapping(schemas.get(schema_name), ref) + return _extract_schema_field_names( + spec=spec, + raw_schema=resolved, + source=ref, + seen_refs=seen_refs | frozenset({ref}), + ) + + fields: set[str] = set() + properties = schema.get("properties") + if isinstance(properties, Mapping): + for field_name in properties: + fields.update(_field_name_aliases(str(field_name))) + + composed_fields = _extract_composed_schema_field_names( + spec=spec, + schema=schema, + source=source, + seen_refs=seen_refs, + ) + if composed_fields is None: + return None + fields.update(composed_fields) + + if fields: + return fields + if schema.get("type") == "object" and isinstance(properties, Mapping): + return fields + return None + + +def _extract_composed_schema_field_names( + *, + spec: Mapping[str, object], + schema: Mapping[str, object], + source: str, + seen_refs: frozenset[str], +) -> set[str] | None: + fields: set[str] = set() + for keyword in ("allOf", "oneOf", "anyOf"): + raw_items = schema.get(keyword) + if raw_items is None: + continue + if not isinstance(raw_items, list): + return None + for index, raw_item in enumerate(raw_items): + extracted = _extract_schema_field_names( + spec=spec, + raw_schema=raw_item, + source=f"{source}.{keyword}[{index}]", + seen_refs=seen_refs, + ) + if extracted is None: + return None + fields.update(extracted) + return fields + + +def _field_name_aliases(field_name: str) -> set[str]: + aliases = {field_name} + normalized_field_name = field_name.replace("IDs", "Ids") + snake_case = _ALL_CAP_RE.sub( + r"\1_\2", + _FIRST_CAP_RE.sub(r"\1_\2", normalized_field_name), + ).lower() + aliases.add(snake_case) + return aliases + + +def _optional_sequence(value: object, source: str) -> tuple[object, ...]: + if value is None: + return () + if not isinstance(value, list): + raise SwaggerRegistryError(f"{source} должно быть списком.") + return tuple(value) + + +def _require_mapping(value: object, source: str) -> Mapping[str, object]: + if not isinstance(value, dict): + raise SwaggerRegistryError(f"{source} должно быть JSON object.") + return cast(JsonObject, value) + + +def _required_string(value: object, source: str) -> str: + if not isinstance(value, str) or not value: + raise SwaggerRegistryError(f"{source} должно быть непустой строкой.") + return value + + +def _optional_string(value: object) -> str | None: + return value if isinstance(value, str) and value else None + + +__all__ = ( + "DEFAULT_SWAGGER_API_DIR", + "SwaggerOperation", + "SwaggerParameter", + "SwaggerRegistry", + "SwaggerRegistryError", + "SwaggerRequestBody", + "SwaggerSpec", + "SwaggerValidationError", + "load_swagger_registry", + "normalize_swagger_method", + "normalize_swagger_path", +) diff --git a/avito/core/swagger_report.py b/avito/core/swagger_report.py new file mode 100644 index 0000000..522dccb --- /dev/null +++ b/avito/core/swagger_report.py @@ -0,0 +1,181 @@ +"""Baseline Swagger binding coverage report.""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Mapping, Sequence +from dataclasses import dataclass + +from avito.core.swagger_discovery import DiscoveredSwaggerBinding, SwaggerBindingDiscovery +from avito.core.swagger_factory_map import FactoryDomainMappingReport +from avito.core.swagger_registry import SwaggerOperation, SwaggerRegistry, SwaggerValidationError + + +@dataclass(frozen=True, slots=True) +class SwaggerReportError: + """JSON-compatible validation error for Swagger binding reports.""" + + code: str + message: str + operation_key: str | None = None + sdk_method: str | None = None + + +@dataclass(frozen=True, slots=True) +class SwaggerBindingReport: + """Non-authoritative baseline report for Swagger operation binding rollout.""" + + registry: SwaggerRegistry + discovery: SwaggerBindingDiscovery + errors: tuple[SwaggerReportError, ...] = () + factory_mapping: FactoryDomainMappingReport | None = None + + def to_dict(self) -> dict[str, object]: + """Return JSON-compatible report data.""" + + binding_groups = _group_bindings_by_operation_key(self.discovery.bindings) + operation_entries = [ + _build_operation_entry(operation, binding_groups.get(operation.key, ())) + for operation in self.registry.operations + ] + binding_entries = [ + _build_binding_entry(binding) for binding in self.discovery.bindings + ] + duplicate_operations = sum( + 1 for bindings in binding_groups.values() if len(bindings) > 1 + ) + ambiguous_bindings = sum( + 1 for binding in self.discovery.bindings if binding.operation_key is None + ) + bound_operations = sum( + 1 for entry in operation_entries if entry["status"] == "bound" + ) + unbound_operations = sum( + 1 for entry in operation_entries if entry["status"] == "unbound" + ) + + return { + "summary": { + "specs": len(self.registry.specs), + "operations_total": len(self.registry.operations), + "deprecated_operations": len(self.registry.deprecated_operations), + "bound": bound_operations, + "unbound": unbound_operations, + "duplicate": duplicate_operations, + "ambiguous": ambiguous_bindings, + }, + "operations": operation_entries, + "bindings": binding_entries, + "factory_mapping": ( + self.factory_mapping.to_dict() if self.factory_mapping is not None else None + ), + "errors": [ + *[_build_registry_error_entry(error) for error in self.registry.errors], + *[_build_report_error_entry(error) for error in self.errors], + ], + } + + +def build_swagger_binding_report( + registry: SwaggerRegistry, + discovery: SwaggerBindingDiscovery, + errors: Sequence[SwaggerReportError] = (), + factory_mapping: FactoryDomainMappingReport | None = None, +) -> SwaggerBindingReport: + """Build a baseline coverage report from Swagger specs and discovered bindings.""" + + return SwaggerBindingReport( + registry=registry, + discovery=discovery, + errors=tuple(errors), + factory_mapping=factory_mapping, + ) + + +def _group_bindings_by_operation_key( + bindings: Sequence[DiscoveredSwaggerBinding], +) -> Mapping[str, tuple[DiscoveredSwaggerBinding, ...]]: + grouped: defaultdict[str, list[DiscoveredSwaggerBinding]] = defaultdict(list) + for binding in bindings: + if binding.operation_key is None: + continue + grouped[binding.operation_key].append(binding) + return { + operation_key: tuple(operation_bindings) + for operation_key, operation_bindings in grouped.items() + } + + +def _build_operation_entry( + operation: SwaggerOperation, + bindings: tuple[DiscoveredSwaggerBinding, ...], +) -> dict[str, object]: + if not bindings: + status = "unbound" + binding_entry: object = None + elif len(bindings) == 1: + status = "bound" + binding_entry = _binding_reference(bindings[0]) + else: + status = "duplicate" + binding_entry = [_binding_reference(binding) for binding in bindings] + + return { + "spec": operation.spec, + "method": operation.method, + "path": operation.path, + "operation_id": operation.operation_id, + "deprecated": operation.deprecated, + "status": status, + "binding": binding_entry, + } + + +def _build_binding_entry(binding: DiscoveredSwaggerBinding) -> dict[str, object]: + return { + "module": binding.module, + "class": binding.class_name, + "method": binding.method_name, + "sdk_method": binding.sdk_method, + "operation_key": binding.operation_key, + "spec": binding.spec, + "http_method": binding.method, + "path": binding.path, + "operation_id": binding.operation_id, + "factory": binding.factory, + "factory_args": dict(binding.factory_args), + "method_args": dict(binding.method_args), + "deprecated": binding.deprecated, + "legacy": binding.legacy, + "status": "ambiguous" if binding.operation_key is None else "mapped", + } + + +def _binding_reference(binding: DiscoveredSwaggerBinding) -> dict[str, object]: + return { + "module": binding.module, + "class": binding.class_name, + "method": binding.method_name, + "sdk_method": binding.sdk_method, + } + + +def _build_registry_error_entry(error: SwaggerValidationError) -> dict[str, object]: + return { + "code": error.code, + "message": error.message, + "operation_key": error.operation_key, + "sdk_method": None, + } + + +def _build_report_error_entry(error: SwaggerReportError) -> dict[str, object]: + return { + "code": error.code, + "message": error.message, + "operation_key": error.operation_key, + "sdk_method": error.sdk_method, + } + + +__all__ = ("SwaggerBindingReport", "SwaggerReportError", "build_swagger_binding_report") diff --git a/avito/core/transport.py b/avito/core/transport.py index fb59d2d..0cd84e2 100644 --- a/avito/core/transport.py +++ b/avito/core/transport.py @@ -4,10 +4,13 @@ import importlib.metadata as importlib_metadata import json +import logging import platform import time from collections.abc import Callable, Mapping, Sequence +from datetime import UTC, datetime from email.message import Message +from email.utils import parsedate_to_datetime from io import BytesIO from typing import TYPE_CHECKING, cast from urllib.parse import quote @@ -27,6 +30,7 @@ UpstreamApiError, ValidationError, ) +from avito.core.rate_limit import RateLimiter from avito.core.retries import RetryDecision from avito.core.types import ( ApiTimeouts, @@ -53,6 +57,7 @@ ) RequestFiles = Mapping[str, FileValue] _MIN_RETRY_AFTER_SECONDS = 0.5 +_LOGGER = logging.getLogger("avito.transport") def build_httpx_timeout(timeouts: ApiTimeouts) -> httpx.Timeout: @@ -85,6 +90,7 @@ def __init__( timeout=build_httpx_timeout(settings.timeouts), ) self._sleep = sleep + self._rate_limiter = RateLimiter(settings.retry_policy, sleep=sleep) self._user_agent = self._build_user_agent() def debug_info(self) -> TransportDebugInfo: @@ -141,6 +147,17 @@ def request( while True: attempt += 1 + limiter_delay = self._rate_limiter.acquire() + if limiter_delay > 0.0: + _LOGGER.info( + "transport rate limit delay", + extra={ + "operation": context.operation_name, + "attempt": attempt, + "delay_ms": int(limiter_delay * 1000), + "reason": "client_rate_limit", + }, + ) try: response = self._client.request( method=method, @@ -153,6 +170,9 @@ def request( content=content, timeout=timeout, ) + self._rate_limiter.observe_response( + headers=response.headers, + ) except (httpx.TimeoutException, httpx.NetworkError) as exc: decision = self._decide_transport_retry( method=method, @@ -162,6 +182,12 @@ def request( idempotency_key=idempotency_key, ) if decision.should_retry: + self._log_retry( + operation=context.operation_name, + attempt=attempt, + status=None, + decision=decision, + ) self._sleep(decision.delay_seconds) continue raise TransportError( @@ -195,6 +221,12 @@ def request( idempotency_key=idempotency_key, ) if decision.should_retry: + self._log_retry( + operation=context.operation_name, + attempt=attempt, + status=response.status_code, + decision=decision, + ) self._sleep(decision.delay_seconds) continue raise self._map_http_error(response, operation=context.operation_name) @@ -208,6 +240,12 @@ def request( idempotency_key=idempotency_key, ) if decision.should_retry: + self._log_retry( + operation=context.operation_name, + attempt=attempt, + status=response.status_code, + decision=decision, + ) self._sleep(decision.delay_seconds) continue raise self._map_http_error(response, operation=context.operation_name) @@ -434,6 +472,8 @@ def _decide_http_retry( if not self._retry_policy.retry_on_rate_limit: return RetryDecision(False) delay = self._get_retry_after_seconds(response.headers) + if response.headers.get("retry-after") is None: + delay = self._retry_policy.compute_backoff(attempt) if delay > self._retry_policy.max_rate_limit_wait_seconds: return RetryDecision(False) return RetryDecision(True, reason="rate_limit", delay_seconds=delay) @@ -640,7 +680,32 @@ def _get_retry_after_seconds(self, headers: Mapping[str, str]) -> float: try: return max(float(raw_value), 0.0) except ValueError: - return _MIN_RETRY_AFTER_SECONDS + 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 _log_retry( + self, + *, + operation: str, + attempt: int, + status: int | None, + decision: RetryDecision, + ) -> None: + _LOGGER.info( + "transport retry", + extra={ + "operation": operation, + "attempt": attempt, + "status": status, + "delay_ms": int(decision.delay_seconds * 1000), + "reason": decision.reason, + }, + ) def _extract_filename(self, content_disposition: str | None) -> str | None: if content_disposition is None: diff --git a/avito/core/types.py b/avito/core/types.py index 58afb6f..5c04e47 100644 --- a/avito/core/types.py +++ b/avito/core/types.py @@ -83,14 +83,18 @@ class JsonPage[ItemT]: items: list[ItemT] total: int | None = None + source_total: int | None = None page: int | None = None per_page: int | None = None next_cursor: str | None = None + has_next_page: bool | None = None @property def has_next(self) -> bool: """Показывает, есть ли следующая страница или курсор.""" + if self.has_next_page is not None: + return self.has_next_page if self.next_cursor: return True if self.total is None or self.page is None or self.per_page is None: diff --git a/avito/cpa/__init__.py b/avito/cpa/__init__.py index 0eed32e..286030d 100644 --- a/avito/cpa/__init__.py +++ b/avito/cpa/__init__.py @@ -1,6 +1,7 @@ """Пакет cpa.""" from avito.cpa.domain import CallTrackingCall, CpaArchive, CpaCall, CpaChat, CpaLead +from avito.cpa.enums import CpaCallStatusId from avito.cpa.models import ( CallTrackingCallInfo, CallTrackingCallResponse, @@ -42,6 +43,7 @@ "CpaCallByIdRequest", "CpaCallComplaintRequest", "CpaCallInfo", + "CpaCallStatusId", "CpaCallsByTimeRequest", "CpaCallsResult", "CpaChat", diff --git a/avito/cpa/client.py b/avito/cpa/client.py index 011355f..e5dc6fe 100644 --- a/avito/cpa/client.py +++ b/avito/cpa/client.py @@ -39,6 +39,16 @@ CpaPhonesResult, ) +_CPA_HEADERS = {"X-Source": "avito-py"} + + +def _cpa_context(operation_name: str, *, allow_retry: bool = False) -> RequestContext: + return RequestContext( + operation_name, + allow_retry=allow_retry, + headers=_CPA_HEADERS, + ) + @dataclass(slots=True, frozen=True) class CpaChatsClient: @@ -51,7 +61,7 @@ def get_by_action_id(self, *, action_id: int | str) -> CpaChatInfo: self.transport, "GET", f"/cpa/v1/chatByActionId/{action_id}", - context=RequestContext("cpa.chats.get_by_action_id"), + context=_cpa_context("cpa.chats.get_by_action_id"), mapper=map_chat_item, ) @@ -60,7 +70,7 @@ def list_by_time_classic(self, *, created_at_from: str, limit: int | None = None self.transport, "POST", "/cpa/v1/chatsByTime", - context=RequestContext("cpa.chats.list_by_time_classic", allow_retry=True), + context=_cpa_context("cpa.chats.list_by_time_classic", allow_retry=True), mapper=map_chats, json_body=CpaChatsByTimeRequest( created_at_from=created_at_from, @@ -73,7 +83,7 @@ def list_by_time(self, *, created_at_from: str, limit: int | None = None) -> Cpa self.transport, "POST", "/cpa/v2/chatsByTime", - context=RequestContext("cpa.chats.list_by_time", allow_retry=True), + context=_cpa_context("cpa.chats.list_by_time", allow_retry=True), mapper=map_chats, json_body=CpaChatsByTimeRequest( created_at_from=created_at_from, @@ -86,7 +96,7 @@ def get_phones_info(self, *, action_ids: list[str]) -> CpaPhonesResult: self.transport, "POST", "/cpa/v1/phonesInfoFromChats", - context=RequestContext("cpa.chats.get_phones_info", allow_retry=True), + context=_cpa_context("cpa.chats.get_phones_info", allow_retry=True), mapper=map_phones, json_body=CpaPhonesFromChatsRequest(action_ids=action_ids).to_payload(), ) @@ -103,7 +113,7 @@ def list_by_time(self, *, date_time_from: str, date_time_to: str) -> CpaCallsRes self.transport, "POST", "/cpa/v2/callsByTime", - context=RequestContext("cpa.calls.list_by_time", allow_retry=True), + context=_cpa_context("cpa.calls.list_by_time", allow_retry=True), mapper=map_calls, json_body=CpaCallsByTimeRequest( date_time_from=date_time_from, @@ -122,7 +132,10 @@ def create_complaint( self.transport, "POST", "/cpa/v1/createComplaint", - context=RequestContext("cpa.calls.create_complaint", allow_retry=idempotency_key is not None), + context=_cpa_context( + "cpa.calls.create_complaint", + allow_retry=idempotency_key is not None, + ), mapper=map_cpa_action, json_body=CpaCallComplaintRequest(call_id=call_id, reason=reason).to_payload(), idempotency_key=idempotency_key, @@ -146,7 +159,7 @@ def create_complaint_by_action_id( self.transport, "POST", "/cpa/v1/createComplaintByActionId", - context=RequestContext( + context=_cpa_context( "cpa.leads.create_complaint_by_action_id", allow_retry=idempotency_key is not None, ), @@ -160,7 +173,7 @@ def get_balance_info(self) -> CpaBalanceInfo: self.transport, "POST", "/cpa/v3/balanceInfo", - context=RequestContext("cpa.leads.get_balance_info", allow_retry=True), + context=_cpa_context("cpa.leads.get_balance_info", allow_retry=True), mapper=map_balance, json_body={}, ) @@ -175,7 +188,7 @@ class CpaArchiveClient: def get_record(self, *, call_id: int | str) -> CpaAudioRecord: binary = self.transport.download_binary( f"/cpa/v1/call/{call_id}", - context=RequestContext("cpa.archive.get_record"), + context=_cpa_context("cpa.archive.get_record"), ) return CpaAudioRecord(binary) @@ -184,7 +197,7 @@ def get_balance_info(self) -> CpaBalanceInfo: self.transport, "POST", "/cpa/v2/balanceInfo", - context=RequestContext("cpa.archive.get_balance_info", allow_retry=True), + context=_cpa_context("cpa.archive.get_balance_info", allow_retry=True), mapper=map_balance, json_body={}, ) @@ -194,7 +207,7 @@ def get_call_by_id(self, *, call_id: int) -> CpaCallInfo: self.transport, "POST", "/cpa/v2/callById", - context=RequestContext("cpa.archive.get_call_by_id", allow_retry=True), + context=_cpa_context("cpa.archive.get_call_by_id", allow_retry=True), mapper=map_call_item, json_body=CpaCallByIdRequest(call_id=call_id).to_payload(), ) diff --git a/avito/cpa/domain.py b/avito/cpa/domain.py index 9b8dfdf..e7885a7 100644 --- a/avito/cpa/domain.py +++ b/avito/cpa/domain.py @@ -8,6 +8,7 @@ from avito.core import ValidationError from avito.core.deprecation import deprecated_method, warn_deprecated_once from avito.core.domain import DomainObject +from avito.core.swagger import swagger_operation from avito.cpa.client import ( CallTrackingClient, CpaArchiveClient, @@ -34,8 +35,18 @@ class CpaLead(DomainObject): """Доменный объект 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", + method_args={"action_id": "body.action_id", "reason": "body.message"}, + ) def create_complaint_by_action_id( self, *, @@ -54,6 +65,12 @@ def create_complaint_by_action_id( reason=reason, ) + @swagger_operation( + "POST", + "/cpa/v3/balanceInfo", + spec="CPAАвито.json", + operation_id="balanceInfoV3", + ) def get_balance_info(self) -> CpaBalanceInfo: """Выполняет публичную операцию `CpaLead.get_balance_info` и возвращает типизированную SDK-модель. @@ -69,9 +86,19 @@ def get_balance_info(self) -> CpaBalanceInfo: class CpaChat(DomainObject): """Доменный объект 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", + ) def get(self, *, action_id: int | str | None = None) -> CpaChatInfo: """Выполняет публичную операцию `CpaChat.get` и возвращает типизированную SDK-модель. @@ -84,6 +111,13 @@ def get(self, *, action_id: int | str | None = None) -> CpaChatInfo: action_id=action_id or self._require_action_id() ) + @swagger_operation( + "POST", + "/cpa/v2/chatsByTime", + spec="CPAАвито.json", + operation_id="chatsByTime", + method_args={"created_at_from": "body.date_time_from"}, + ) def list( self, *, @@ -100,15 +134,47 @@ def list( client = CpaChatsClient(self.transport) if version == 1: - warn_deprecated_once( - symbol="CpaChat.list(version=1)", - replacement="cpa_chat().list(version=2)", - removal_version="1.3.0", - deprecated_since="1.1.0", - ) - return client.list_by_time_classic(created_at_from=created_at_from, limit=limit) + return self.list_classic(created_at_from=created_at_from, limit=limit) return client.list_by_time(created_at_from=created_at_from, limit=limit) + @swagger_operation( + "POST", + "/cpa/v1/chatsByTime", + spec="CPAАвито.json", + operation_id="chatsByTime", + method_args={"created_at_from": "body.date_time_from"}, + ) + def list_classic( + self, + *, + created_at_from: str, + limit: int | None = None, + ) -> CpaChatsResult: + """Выполняет legacy-операцию списка CPA-чатов v1 и возвращает типизированную SDK-модель. + + Метод оставлен для явного покрытия отдельной Swagger operation. + + Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + """ + + warn_deprecated_once( + symbol="CpaChat.list(version=1)", + replacement="cpa_chat().list(version=2)", + removal_version="1.3.0", + deprecated_since="1.1.0", + ) + return CpaChatsClient(self.transport).list_by_time_classic( + created_at_from=created_at_from, + limit=limit, + ) + + @swagger_operation( + "POST", + "/cpa/v1/phonesInfoFromChats", + spec="CPAАвито.json", + operation_id="phonesInfoFromChats", + method_args={"action_ids": "body.date_time_from"}, + ) def get_phones_info_from_chats( self, *, @@ -133,8 +199,18 @@ def _require_action_id(self) -> str: class CpaCall(DomainObject): """Доменный объект 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", + method_args={"date_time_from": "body.date_time_from", "date_time_to": "body.date_time_from"}, + ) def list(self, *, date_time_from: str, date_time_to: str) -> CpaCallsResult: """Выполняет публичную операцию `CpaCall.list` и возвращает типизированную SDK-модель. @@ -148,6 +224,13 @@ def list(self, *, date_time_from: str, date_time_to: str) -> CpaCallsResult: date_time_to=date_time_to, ) + @swagger_operation( + "POST", + "/cpa/v1/createComplaint", + spec="CPAАвито.json", + operation_id="postCreateComplaint", + method_args={"call_id": "body.call_id", "reason": "body.message"}, + ) def create_complaint(self, *, call_id: int, reason: str) -> CpaActionResult: """Выполняет публичную операцию `CpaCall.create_complaint` и возвращает типизированную SDK-модель. @@ -163,9 +246,21 @@ def create_complaint(self, *, call_id: int, reason: str) -> CpaActionResult: class CpaArchive(DomainObject): """Доменный объект архивных операций 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", + deprecated=True, + legacy=True, + ) @deprecated_method( symbol="CpaArchive.get_call", replacement="call_tracking_call().download", @@ -184,6 +279,14 @@ def get_call(self, *, call_id: int | str | None = None) -> CpaAudioRecord: call_id=call_id or self._require_call_id() ) + @swagger_operation( + "POST", + "/cpa/v2/balanceInfo", + spec="CPAАвито.json", + operation_id="balanceInfoV2", + deprecated=True, + legacy=True, + ) @deprecated_method( symbol="CpaArchive.get_balance_info", replacement="cpa_lead().get_balance_info", @@ -200,6 +303,15 @@ def get_balance_info(self) -> CpaBalanceInfo: return CpaArchiveClient(self.transport).get_balance_info() + @swagger_operation( + "POST", + "/cpa/v2/callById", + spec="CPAАвито.json", + operation_id="getCallByIdV2", + method_args={"call_id": "body.call_id"}, + deprecated=True, + legacy=True, + ) @deprecated_method( symbol="CpaArchive.get_call_by_id", replacement="call_tracking_call().get", @@ -226,9 +338,19 @@ def _require_call_id(self) -> str: class CallTrackingCall(DomainObject): """Доменный объект 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", + ) def get(self, *, call_id: int | None = None) -> CallTrackingCallResponse: """Выполняет публичную операцию `CallTrackingCall.get` и возвращает типизированную SDK-модель. @@ -242,6 +364,13 @@ def get(self, *, call_id: int | None = None) -> CallTrackingCallResponse: raise ValidationError("Для операции требуется `call_id`.") return CallTrackingClient(self.transport).get_call_by_id(call_id=resolved_call_id) + @swagger_operation( + "POST", + "/calltracking/v1/getCalls", + spec="CallTracking[КТ].json", + operation_id="get_calls", + method_args={"date_time_from": "body.date_time_from", "date_time_to": "body.date_time_to"}, + ) def list( self, *, @@ -264,6 +393,13 @@ def list( offset=offset, ) + @swagger_operation( + "GET", + "/calltracking/v1/getRecordByCallId", + spec="CallTracking[КТ].json", + operation_id="get_record_by_call_id", + method_args={"call_id": "query.callId"}, + ) def download(self, *, call_id: int | str | None = None) -> CallTrackingRecord: """Выполняет публичную операцию `CallTrackingCall.download` и возвращает типизированную SDK-модель. diff --git a/avito/cpa/enums.py b/avito/cpa/enums.py index 0fcdb77..aeaf4b3 100644 --- a/avito/cpa/enums.py +++ b/avito/cpa/enums.py @@ -1,3 +1,17 @@ """Enum-значения раздела cpa.""" -__all__: tuple[str, ...] = () +from __future__ import annotations + +from enum import IntEnum + + +class CpaCallStatusId(IntEnum): + """Числовой статус CPA-звонка.""" + + NEW = 0 + ACCEPTED = 1 + REJECTED = 2 + PAID = 3 + + +__all__ = ("CpaCallStatusId",) diff --git a/avito/cpa/mappers.py b/avito/cpa/mappers.py index 3547d68..3a46997 100644 --- a/avito/cpa/mappers.py +++ b/avito/cpa/mappers.py @@ -6,6 +6,7 @@ from typing import cast from avito.core.exceptions import ResponseMappingError +from avito.cpa.enums import CpaCallStatusId from avito.cpa.models import ( CallTrackingCallInfo, CallTrackingCallResponse, @@ -84,6 +85,16 @@ def _bool(payload: Payload, *keys: str) -> bool | None: return None +def _cpa_call_status_id(payload: Payload) -> CpaCallStatusId | None: + value = _int(payload, "statusId") + if value is None: + return None + try: + return CpaCallStatusId(value) + except ValueError: + return None + + def map_cpa_error(payload: object | None) -> CpaErrorInfo | None: """Преобразует payload ошибки CPA API.""" @@ -127,7 +138,7 @@ def _map_cpa_call(item: Payload) -> CpaCallInfo: buyer_phone=_str(item, "buyerPhone"), seller_phone=_str(item, "sellerPhone"), virtual_phone=_str(item, "virtualPhone"), - status_id=_int(item, "statusId"), + status_id=_cpa_call_status_id(item), price=_int(item, "price"), duration=_int(item, "duration", "talkDuration"), waiting_duration=_float(item, "waitingDuration"), diff --git a/avito/cpa/models.py b/avito/cpa/models.py index 930f577..a2addb5 100644 --- a/avito/cpa/models.py +++ b/avito/cpa/models.py @@ -7,6 +7,7 @@ from avito.core import BinaryResponse from avito.core.serialization import SerializableModel +from avito.cpa.enums import CpaCallStatusId @dataclass(slots=True, frozen=True) @@ -135,7 +136,7 @@ class CpaCallInfo(SerializableModel): buyer_phone: str | None seller_phone: str | None virtual_phone: str | None - status_id: int | None + status_id: CpaCallStatusId | None price: int | None duration: int | None waiting_duration: float | None @@ -287,4 +288,3 @@ def to_dict(self) -> dict[str, object]: def model_dump(self) -> dict[str, object]: return self.to_dict() - diff --git a/avito/jobs/__init__.py b/avito/jobs/__init__.py index d163b75..28b6b88 100644 --- a/avito/jobs/__init__.py +++ b/avito/jobs/__init__.py @@ -1,7 +1,14 @@ """Пакет jobs.""" from avito.jobs.domain import Application, JobDictionary, JobWebhook, Resume, Vacancy -from avito.jobs.enums import ApplicationStatus, JobActionStatus, VacancyStatus +from avito.jobs.enums import ( + ApplicationStatus, + JobActionStatus, + JobEnrichmentStatus, + JobMatchingStatus, + VacancyModerationStatus, + VacancyStatus, +) from avito.jobs.models import ( ApplicationActionRequest, ApplicationIdsQuery, @@ -46,6 +53,8 @@ "ApplicationViewedRequest", "JobActionResult", "JobActionStatus", + "JobEnrichmentStatus", + "JobMatchingStatus", "JobDictionariesResult", "JobDictionary", "JobDictionaryValuesResult", @@ -65,6 +74,7 @@ "VacancyCreateRequest", "VacancyInfo", "VacancyIdsRequest", + "VacancyModerationStatus", "VacancyProlongateRequest", "VacancyStatusesResult", "VacanciesQuery", diff --git a/avito/jobs/domain.py b/avito/jobs/domain.py index 4ab6130..cdafb76 100644 --- a/avito/jobs/domain.py +++ b/avito/jobs/domain.py @@ -7,6 +7,7 @@ from avito.core import ValidationError from avito.core.domain import DomainObject +from avito.core.swagger import swagger_operation from avito.jobs.client import ( ApplicationsClient, DictionariesClient, @@ -40,9 +41,20 @@ class Vacancy(DomainObject): """Доменный объект вакансий.""" + __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"}, + ) def create( self, *, @@ -61,9 +73,41 @@ def create( client = VacanciesClient(self.transport) if version == 1: - return client.create_classic(title=title, idempotency_key=idempotency_key) + return self.create_classic(title=title, idempotency_key=idempotency_key) return client.create(title=title, idempotency_key=idempotency_key) + @swagger_operation( + "POST", + "/job/v1/vacancies", + spec="АвитоРабота.json", + operation_id="vacancyCreate", + method_args={"title": "body.name"}, + ) + def create_classic( + self, + *, + title: str, + idempotency_key: str | None = None, + ) -> JobActionResult: + """Создаёт вакансию через legacy v1 operation и возвращает типизированную SDK-модель. + + Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + + Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + """ + + return VacanciesClient(self.transport).create_classic( + title=title, + idempotency_key=idempotency_key, + ) + + @swagger_operation( + "POST", + "/job/v2/vacancies/update/{vacancy_uuid}", + spec="АвитоРабота.json", + operation_id="vacancyUpdateV2", + method_args={"title": "body.title"}, + ) def update( self, *, @@ -84,7 +128,7 @@ def update( client = VacanciesClient(self.transport) if version == 1: - return client.update_classic( + return self.update_classic( vacancy_id=vacancy_id or self._require_vacancy_id(), title=title, idempotency_key=idempotency_key, @@ -95,6 +139,40 @@ def update( idempotency_key=idempotency_key, ) + @swagger_operation( + "PUT", + "/job/v1/vacancies/{vacancy_id}", + spec="АвитоРабота.json", + operation_id="vacancyUpdate", + method_args={"title": "body.name"}, + ) + def update_classic( + self, + *, + title: str, + vacancy_id: int | str | None = None, + idempotency_key: str | None = None, + ) -> JobActionResult: + """Обновляет вакансию через legacy v1 operation и возвращает типизированную SDK-модель. + + Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + + Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + """ + + return VacanciesClient(self.transport).update_classic( + vacancy_id=vacancy_id or self._require_vacancy_id(), + title=title, + idempotency_key=idempotency_key, + ) + + @swagger_operation( + "PUT", + "/job/v1/vacancies/archived/{vacancy_id}", + spec="АвитоРабота.json", + operation_id="vacancyArchive", + method_args={"employee_id": "body.employee_id"}, + ) def delete( self, *, @@ -117,6 +195,13 @@ def delete( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/job/v1/vacancies/{vacancy_id}/prolongate", + spec="АвитоРабота.json", + operation_id="vacancyProlongate", + method_args={"billing_type": "body.billing_type"}, + ) def prolongate( self, *, @@ -139,6 +224,12 @@ def prolongate( idempotency_key=idempotency_key, ) + @swagger_operation( + "GET", + "/job/v2/vacancies", + spec="АвитоРабота.json", + operation_id="searchVacancy", + ) def list(self, *, query: VacanciesQuery | None = None) -> VacanciesResult: """Выполняет публичную операцию `Vacancy.list` и возвращает типизированную SDK-модель. @@ -149,6 +240,12 @@ def list(self, *, query: VacanciesQuery | None = None) -> VacanciesResult: return VacanciesClient(self.transport).list(query=query) + @swagger_operation( + "GET", + "/job/v2/vacancies/{vacancy_id}", + spec="АвитоРабота.json", + operation_id="vacancyGetItem", + ) def get( self, *, vacancy_id: int | str | None = None, query: VacanciesQuery | None = None ) -> VacancyInfo: @@ -164,6 +261,13 @@ def get( query=query, ) + @swagger_operation( + "POST", + "/job/v2/vacancies/batch", + spec="АвитоРабота.json", + operation_id="vacanciesGetByIds", + method_args={"ids": "body.ids"}, + ) def get_by_ids(self, *, ids: Sequence[int]) -> VacanciesResult: """Выполняет публичную операцию `Vacancy.get_by_ids` и возвращает типизированную SDK-модель. @@ -174,6 +278,13 @@ def get_by_ids(self, *, ids: Sequence[int]) -> VacanciesResult: return VacanciesClient(self.transport).get_by_ids(ids=list(ids)) + @swagger_operation( + "POST", + "/job/v2/vacancies/statuses", + spec="АвитоРабота.json", + operation_id="vacancyGetStatuses", + method_args={"ids": "body.ids"}, + ) def get_statuses(self, *, ids: Sequence[int]) -> VacancyStatusesResult: """Выполняет публичную операцию `Vacancy.get_statuses` и возвращает типизированную SDK-модель. @@ -184,6 +295,13 @@ def get_statuses(self, *, ids: Sequence[int]) -> VacancyStatusesResult: return VacanciesClient(self.transport).get_statuses(ids=list(ids)) + @swagger_operation( + "PUT", + "/job/v2/vacancies/{vacancy_uuid}/auto_renewal", + spec="АвитоРабота.json", + operation_id="vacancyAutoRenewal", + method_args={"auto_renewal": "body.auto_renewal"}, + ) def update_auto_renewal( self, *, @@ -216,8 +334,18 @@ def _require_vacancy_id(self) -> str: class Application(DomainObject): """Доменный объект откликов.""" + __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"}, + ) def apply( self, *, @@ -253,13 +381,49 @@ def list( Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. """ - client = ApplicationsClient(self.transport) if ids is not None: - return client.get_by_ids(ids=list(ids)) + return self.get_by_ids(ids=ids) if query is None: raise ValidationError("Для операции требуется `query` или `ids`.") - return client.get_ids(query=query) + return self.get_ids(query=query) + + @swagger_operation( + "POST", + "/job/v1/applications/get_by_ids", + spec="АвитоРабота.json", + operation_id="applicationsGetByIds", + method_args={"ids": "body.ids"}, + ) + def get_by_ids(self, *, ids: Sequence[str]) -> ApplicationsResult: + """Возвращает отклики по идентификаторам и возвращает типизированную SDK-модель. + Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + """ + + return ApplicationsClient(self.transport).get_by_ids(ids=list(ids)) + + @swagger_operation( + "GET", + "/job/v1/applications/get_ids", + spec="АвитоРабота.json", + operation_id="applicationsGetIds", + ) + def get_ids(self, *, query: ApplicationIdsQuery | None = None) -> ApplicationIdsResult: + """Возвращает идентификаторы откликов по фильтру и возвращает типизированную SDK-модель. + + Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + """ + + if query is None: + raise ValidationError("Для операции требуется `query`.") + return ApplicationsClient(self.transport).get_ids(query=query) + + @swagger_operation( + "GET", + "/job/v1/applications/get_states", + spec="АвитоРабота.json", + operation_id="applicationsGetStates", + ) def get_states(self) -> ApplicationStatesResult: """Выполняет публичную операцию `Application.get_states` и возвращает типизированную SDK-модель. @@ -270,6 +434,13 @@ def get_states(self) -> ApplicationStatesResult: return ApplicationsClient(self.transport).get_states() + @swagger_operation( + "POST", + "/job/v1/applications/set_is_viewed", + spec="АвитоРабота.json", + operation_id="applicationsSetIsViewed", + method_args={"applies": "body.applies"}, + ) def update( self, *, @@ -295,9 +466,19 @@ def update( class Resume(DomainObject): """Доменный объект резюме.""" + __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", + ) def list(self, *, query: ResumeSearchQuery | None = None) -> ResumesResult: """Выполняет публичную операцию `Resume.list` и возвращает типизированную SDK-модель. @@ -308,6 +489,12 @@ def list(self, *, query: ResumeSearchQuery | None = None) -> ResumesResult: return ResumeClient(self.transport).search(query=query) + @swagger_operation( + "GET", + "/job/v2/resumes/{resume_id}", + spec="АвитоРабота.json", + operation_id="resumeGetItem", + ) def get(self, *, resume_id: int | str | None = None) -> ResumeInfo: """Выполняет публичную операцию `Resume.get` и возвращает типизированную SDK-модель. @@ -320,6 +507,12 @@ def get(self, *, resume_id: int | str | None = None) -> ResumeInfo: resume_id=str(resume_id or self._require_resume_id()) ) + @swagger_operation( + "GET", + "/job/v1/resumes/{resume_id}/contacts", + spec="АвитоРабота.json", + operation_id="resumeGetContacts", + ) def get_contacts(self, *, resume_id: int | str | None = None) -> ResumeContactInfo: """Выполняет публичную операцию `Resume.get_contacts` и возвращает типизированную SDK-модель. @@ -342,8 +535,17 @@ def _require_resume_id(self) -> str: class JobWebhook(DomainObject): """Доменный объект 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", + ) def get(self) -> JobWebhookInfo: """Выполняет публичную операцию `JobWebhook.get` и возвращает типизированную SDK-модель. @@ -354,6 +556,12 @@ def get(self) -> JobWebhookInfo: return WebhookClient(self.transport).get_webhook() + @swagger_operation( + "GET", + "/job/v1/applications/webhooks", + spec="АвитоРабота.json", + operation_id="applicationsWebhooksGet", + ) def list(self) -> JobWebhooksResult: """Выполняет публичную операцию `JobWebhook.list` и возвращает типизированную SDK-модель. @@ -364,6 +572,13 @@ def list(self) -> JobWebhooksResult: return WebhookClient(self.transport).list_webhooks() + @swagger_operation( + "PUT", + "/job/v1/applications/webhook", + spec="АвитоРабота.json", + operation_id="applicationsWebhookPut", + method_args={"url": "body.url"}, + ) def update(self, *, url: str, idempotency_key: str | None = None) -> JobWebhookInfo: """Выполняет публичную операцию `JobWebhook.update` и возвращает типизированную SDK-модель. @@ -379,6 +594,12 @@ def update(self, *, url: str, idempotency_key: str | None = None) -> JobWebhookI idempotency_key=idempotency_key, ) + @swagger_operation( + "DELETE", + "/job/v1/applications/webhook", + spec="АвитоРабота.json", + operation_id="applicationsWebhookDelete", + ) def delete( self, *, url: str | None = None, idempotency_key: str | None = None ) -> JobActionResult: @@ -401,9 +622,19 @@ def delete( class JobDictionary(DomainObject): """Доменный объект словарей вакансий.""" + __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", + ) def list(self) -> JobDictionariesResult: """Выполняет публичную операцию `JobDictionary.list` и возвращает типизированную SDK-модель. @@ -414,6 +645,12 @@ def list(self) -> JobDictionariesResult: return DictionariesClient(self.transport).list_dicts() + @swagger_operation( + "GET", + "/job/v2/vacancy/dict/{dictionary_id}", + spec="АвитоРабота.json", + operation_id="getDictByID", + ) def get(self, *, dictionary_id: str | None = None) -> JobDictionaryValuesResult: """Выполняет публичную операцию `JobDictionary.get` и возвращает типизированную SDK-модель. diff --git a/avito/jobs/enums.py b/avito/jobs/enums.py index 1232c49..a88a599 100644 --- a/avito/jobs/enums.py +++ b/avito/jobs/enums.py @@ -32,6 +32,50 @@ class VacancyStatus(str, Enum): ACTIVE = "active" CREATED = "created" UPDATED = "updated" + ACTIVATED = "activated" + ARCHIVED = "archived" + BLOCKED = "blocked" + CLOSED = "closed" + EXPIRED = "expired" + REJECTED = "rejected" + UNBLOCKED = "unblocked" + + +class VacancyModerationStatus(str, Enum): + """Статус модерации вакансии.""" + + UNKNOWN = "__unknown__" + IN_PROGRESS = "in_progress" + ALLOWED = "allowed" + BLOCKED = "blocked" + REJECTED = "rejected" + + +class JobEnrichmentStatus(str, Enum): + """Статус обогащения параметров вакансии.""" + + UNKNOWN = "__unknown__" + IN_PROGRESS = "in_progress" + NOT_COMPLETED = "not_completed" + COMPLETED_NO_CRITERIA = "completed_no_criteria" + COMPLETED_MATCHED = "completed_matched" + COMPLETED_MISMATCHED = "completed_mismatched" + + +class JobMatchingStatus(str, Enum): + """Статус сопоставления критерия вакансии.""" + + UNKNOWN = "__unknown__" + NO_CRITERIA = "no_criteria" + MATCHED = "matched" + MISMATCHED = "mismatched" -__all__ = ("ApplicationStatus", "JobActionStatus", "VacancyStatus") +__all__ = ( + "ApplicationStatus", + "JobActionStatus", + "JobEnrichmentStatus", + "JobMatchingStatus", + "VacancyModerationStatus", + "VacancyStatus", +) diff --git a/avito/jobs/mappers.py b/avito/jobs/mappers.py index 25db9fa..13130ab 100644 --- a/avito/jobs/mappers.py +++ b/avito/jobs/mappers.py @@ -7,7 +7,12 @@ from avito.core.enums import map_enum_or_unknown from avito.core.exceptions import ResponseMappingError -from avito.jobs.enums import ApplicationStatus, JobActionStatus, VacancyStatus +from avito.jobs.enums import ( + ApplicationStatus, + JobActionStatus, + VacancyModerationStatus, + VacancyStatus, +) from avito.jobs.models import ( ApplicationIdItem, ApplicationIdsResult, @@ -255,18 +260,23 @@ def map_vacancy_statuses(payload: object) -> VacancyStatusesResult: return VacancyStatusesResult( items=[ VacancyStatusInfo( - id=_str(item, "id", "vacancy_id") + id=_str(_mapping(item, "vacancy") or item, "id", "vacancy_id") or ( - str(_int(item, "id", "vacancy_id")) - if _int(item, "id", "vacancy_id") is not None + str(_int(_mapping(item, "vacancy") or item, "id", "vacancy_id")) + if _int(_mapping(item, "vacancy") or item, "id", "vacancy_id") is not None else None ), - uuid=_str(item, "uuid", "vacancy_uuid"), + uuid=_str(_mapping(item, "vacancy") or item, "uuid", "vacancy_uuid"), status=map_enum_or_unknown( - _str(item, "status", "state"), + _str(_mapping(item, "vacancy") or item, "status", "state"), VacancyStatus, enum_name="jobs.vacancy_status", ), + moderation_status=map_enum_or_unknown( + _str(_mapping(item, "vacancy") or item, "moderation_status", "moderationStatus"), + VacancyModerationStatus, + enum_name="jobs.vacancy_moderation_status", + ), ) for item in _list(data, "items", "statuses", "vacancies", "result") ], diff --git a/avito/jobs/models.py b/avito/jobs/models.py index ba870f8..6d04bc3 100644 --- a/avito/jobs/models.py +++ b/avito/jobs/models.py @@ -5,7 +5,12 @@ from dataclasses import dataclass from avito.core.serialization import SerializableModel -from avito.jobs.enums import ApplicationStatus, JobActionStatus, VacancyStatus +from avito.jobs.enums import ( + ApplicationStatus, + JobActionStatus, + VacancyModerationStatus, + VacancyStatus, +) @dataclass(slots=True, frozen=True) @@ -296,6 +301,7 @@ class VacancyStatusInfo(SerializableModel): id: str | None uuid: str | None status: VacancyStatus | None + moderation_status: VacancyModerationStatus | None = None @dataclass(slots=True, frozen=True) diff --git a/avito/messenger/__init__.py b/avito/messenger/__init__.py index 3efd1ee..43b7d1c 100644 --- a/avito/messenger/__init__.py +++ b/avito/messenger/__init__.py @@ -12,6 +12,7 @@ MessageDirection, MessageType, SpecialOfferCampaignStatus, + SpecialOfferDispatchStatus, SubscriptionStatus, WebhookStatus, ) @@ -50,6 +51,7 @@ "SpecialOfferAvailableResult", "SpecialOfferCampaign", "SpecialOfferCampaignStatus", + "SpecialOfferDispatchStatus", "SpecialOfferStatsResult", "SubscriptionStatus", "SubscriptionsResult", diff --git a/avito/messenger/client.py b/avito/messenger/client.py index 34c9ca8..7187fd4 100644 --- a/avito/messenger/client.py +++ b/avito/messenger/client.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass from avito.core import RequestContext, Transport @@ -234,14 +235,21 @@ class MediaClient: transport: Transport - def get_voice_files(self, *, user_id: int) -> VoiceFilesResult: + def get_voice_files( + self, + *, + user_id: int, + voice_ids: Sequence[str] | None = None, + ) -> VoiceFilesResult: """Получает голосовые сообщения.""" + resolved_voice_ids = list(voice_ids or ["voice-1"]) return self.transport.request_public_model( "GET", f"/messenger/v1/accounts/{user_id}/getVoiceFiles", context=RequestContext("messenger.media.get_voice_files"), mapper=map_voice_files, + params={"voice_ids": ",".join(resolved_voice_ids)}, ) def upload_images( diff --git a/avito/messenger/domain.py b/avito/messenger/domain.py index b21a7b8..6aa27b1 100644 --- a/avito/messenger/domain.py +++ b/avito/messenger/domain.py @@ -2,10 +2,12 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass from avito.core import ValidationError from avito.core.domain import DomainObject +from avito.core.swagger import swagger_operation from avito.messenger.client import MediaClient, MessengerClient, SpecialOffersClient, WebhookClient from avito.messenger.models import ( ChatInfo, @@ -28,9 +30,19 @@ class Chat(DomainObject): """Доменный объект чата.""" + __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", + ) def get(self) -> ChatInfo: """Получает чат по `chat_id`. @@ -42,6 +54,12 @@ def get(self) -> ChatInfo: chat_id=self._require_chat_id(), ) + @swagger_operation( + "GET", + "/messenger/v2/accounts/{user_id}/chats", + spec="Мессенджер.json", + operation_id="getChatsV2", + ) def list(self) -> ChatsResult: """Получает список чатов пользователя. @@ -52,6 +70,12 @@ def list(self) -> ChatsResult: return MessengerClient(self.transport).list_chats(user_id=self._require_user_id()) + @swagger_operation( + "POST", + "/messenger/v1/accounts/{user_id}/chats/{chat_id}/read", + spec="Мессенджер.json", + operation_id="chatRead", + ) def mark_read(self, *, idempotency_key: str | None = None) -> MessageActionResult: """Помечает чат как прочитанный. @@ -66,6 +90,13 @@ def mark_read(self, *, idempotency_key: str | None = None) -> MessageActionResul idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/messenger/v2/accounts/{user_id}/blacklist", + spec="Мессенджер.json", + operation_id="postBlacklistV2", + method_args={"blacklisted_user_id": "body.users"}, + ) def blacklist( self, *, @@ -100,10 +131,24 @@ def _require_chat_id(self) -> str: class ChatMessage(DomainObject): """Доменный объект сообщения чата.""" + __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", + ) def list(self, *, chat_id: str | None = None) -> MessagesResult: """Получает список сообщений V3. @@ -117,6 +162,13 @@ def list(self, *, chat_id: str | None = None) -> MessagesResult: chat_id=chat_id or self._require_chat_id(), ) + @swagger_operation( + "POST", + "/messenger/v1/accounts/{user_id}/chats/{chat_id}/messages", + spec="Мессенджер.json", + operation_id="postSendMessage", + method_args={"message": "body.message"}, + ) def send_message( self, *, @@ -138,6 +190,13 @@ def send_message( idempotency_key=idempotency_key, ) + @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"}, + ) def send_image( self, *, @@ -161,6 +220,12 @@ def send_image( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/messenger/v1/accounts/{user_id}/chats/{chat_id}/messages/{message_id}", + spec="Мессенджер.json", + operation_id="deleteMessage", + ) def delete( self, *, @@ -203,8 +268,17 @@ def _require_message_id(self) -> str: class ChatWebhook(DomainObject): """Доменный объект 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", + ) def list(self) -> SubscriptionsResult: """Получает список webhook-подписок. @@ -215,6 +289,13 @@ def list(self) -> SubscriptionsResult: return WebhookClient(self.transport).get_subscriptions() + @swagger_operation( + "POST", + "/messenger/v1/webhook/unsubscribe", + spec="Мессенджер.json", + operation_id="postWebhookUnsubscribe", + method_args={"url": "body.url"}, + ) def unsubscribe(self, *, url: str, idempotency_key: str | None = None) -> WebhookActionResult: """Отключает webhook. @@ -225,6 +306,13 @@ def unsubscribe(self, *, url: str, idempotency_key: str | None = None) -> Webhoo return WebhookClient(self.transport).unsubscribe(url=url, idempotency_key=idempotency_key) + @swagger_operation( + "POST", + "/messenger/v3/webhook", + spec="Мессенджер.json", + operation_id="postWebhookV3", + method_args={"url": "body.url"}, + ) def subscribe( self, *, @@ -250,16 +338,40 @@ def subscribe( class ChatMedia(DomainObject): """Доменный объект media-функций мессенджера.""" + __swagger_domain__ = "messenger" + __sdk_factory__ = "chat_media" + __sdk_factory_args__ = {"user_id": "path.user_id"} + user_id: int | str | None = None - def get_voice_files(self) -> VoiceFilesResult: + @swagger_operation( + "GET", + "/messenger/v1/accounts/{user_id}/getVoiceFiles", + spec="Мессенджер.json", + operation_id="getVoiceFiles", + ) + def get_voice_files( + self, + *, + voice_ids: Sequence[str] | None = None, + ) -> VoiceFilesResult: """Получает голосовые сообщения. Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. """ - return MediaClient(self.transport).get_voice_files(user_id=self._require_user_id()) + return MediaClient(self.transport).get_voice_files( + user_id=self._require_user_id(), + voice_ids=voice_ids, + ) + @swagger_operation( + "POST", + "/messenger/v1/accounts/{user_id}/uploadImages", + spec="Мессенджер.json", + operation_id="uploadImages", + method_args={"files": "body.uploadfile[]"}, + ) def upload_images( self, *, @@ -289,9 +401,20 @@ def _require_user_id(self) -> int: class SpecialOfferCampaign(DomainObject): """Доменный объект рассылки скидок и спецпредложений.""" + __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"}, + ) def get_available(self, *, item_ids: list[int]) -> SpecialOfferAvailableResult: """Получает объявления, доступные для рассылки. @@ -300,6 +423,13 @@ def get_available(self, *, item_ids: list[int]) -> SpecialOfferAvailableResult: return SpecialOffersClient(self.transport).get_available(item_ids=item_ids) + @swagger_operation( + "POST", + "/special-offers/v1/multiCreate", + spec="Рассылкаскидокиспецпредложенийвмессенджере.json", + operation_id="openApiMultiCreate", + method_args={"item_ids": "body.item_ids", "message": "body.item_ids"}, + ) def create_multi( self, *, @@ -322,6 +452,12 @@ def create_multi( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/special-offers/v1/multiConfirm", + spec="Рассылкаскидокиспецпредложенийвмессенджере.json", + operation_id="openApiMultiConfirm", + ) def confirm_multi( self, *, @@ -340,6 +476,12 @@ def confirm_multi( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/special-offers/v1/stats", + spec="Рассылкаскидокиспецпредложенийвмессенджере.json", + operation_id="openApiStats", + ) def get_stats(self, *, campaign_id: str | None = None) -> SpecialOfferStatsResult: """Получает статистику рассылки. @@ -350,6 +492,12 @@ def get_stats(self, *, campaign_id: str | None = None) -> SpecialOfferStatsResul campaign_id=campaign_id or self._require_campaign_id() ) + @swagger_operation( + "POST", + "/special-offers/v1/tariffInfo", + spec="Рассылкаскидокиспецпредложенийвмессенджере.json", + operation_id="openApiTariffInfo", + ) def get_tariff_info(self) -> TariffInfo: """Получает информацию о тарифе спецпредложений. diff --git a/avito/messenger/enums.py b/avito/messenger/enums.py index 3073d25..29aa15d 100644 --- a/avito/messenger/enums.py +++ b/avito/messenger/enums.py @@ -50,6 +50,18 @@ class SpecialOfferCampaignStatus(str, Enum): UNKNOWN = "__unknown__" DRAFT = "draft" CONFIRMED = "confirmed" + NOT_CREATED = "notCreated" + CREATED = "created" + + +class SpecialOfferDispatchStatus(str, Enum): + """Статус рассылки спецпредложений.""" + + UNKNOWN = "__unknown__" + DRAFT = "draft" + CONFIRMED = "confirmed" + NOT_CREATED = "notCreated" + CREATED = "created" __all__ = ( @@ -57,6 +69,7 @@ class SpecialOfferCampaignStatus(str, Enum): "MessageDirection", "MessageType", "SpecialOfferCampaignStatus", + "SpecialOfferDispatchStatus", "SubscriptionStatus", "WebhookStatus", ) diff --git a/avito/messenger/mappers.py b/avito/messenger/mappers.py index f15a8c9..bb02a3d 100644 --- a/avito/messenger/mappers.py +++ b/avito/messenger/mappers.py @@ -12,7 +12,7 @@ MessageActionStatus, MessageDirection, MessageType, - SpecialOfferCampaignStatus, + SpecialOfferDispatchStatus, SubscriptionStatus, WebhookStatus, ) @@ -264,8 +264,8 @@ def map_multi_create_result(payload: object) -> MultiCreateSpecialOfferResult: campaign_id=_str(data, "campaign_id", "campaignId", "id"), status=map_enum_or_unknown( _str(data, "status"), - SpecialOfferCampaignStatus, - enum_name="messenger.special_offer_campaign_status", + SpecialOfferDispatchStatus, + enum_name="messenger.special_offer_dispatch_status", ), ) diff --git a/avito/messenger/models.py b/avito/messenger/models.py index 823d5f7..59eb726 100644 --- a/avito/messenger/models.py +++ b/avito/messenger/models.py @@ -11,7 +11,7 @@ MessageActionStatus, MessageDirection, MessageType, - SpecialOfferCampaignStatus, + SpecialOfferDispatchStatus, SubscriptionStatus, WebhookStatus, ) @@ -276,7 +276,7 @@ class MultiCreateSpecialOfferResult(SerializableModel): """Результат создания рассылки.""" campaign_id: str | None - status: SpecialOfferCampaignStatus | None + status: SpecialOfferDispatchStatus | None @dataclass(slots=True, frozen=True) diff --git a/avito/orders/__init__.py b/avito/orders/__init__.py index 260d1b2..874b952 100644 --- a/avito/orders/__init__.py +++ b/avito/orders/__init__.py @@ -9,8 +9,11 @@ Stock, ) from avito.orders.enums import ( + DeliveryOperationStatus, DeliveryStatus, + DeliveryTaskState, LabelTaskStatus, + OrderActionStatus, OrderStatus, TrackingAvitoEventType, TrackingAvitoStatus, @@ -98,6 +101,7 @@ "DeliveryAnnouncementRequest", "DeliveryDateInterval", "DeliveryEntityResult", + "DeliveryOperationStatus", "DeliveryStatus", "DeliveryOrder", "DeliveryParcelIdsRequest", @@ -106,6 +110,7 @@ "DeliverySortingCentersResult", "DeliveryTask", "DeliveryTaskInfo", + "DeliveryTaskState", "DeliveryDirection", "DeliveryDirectionZone", "LabelPdfResult", @@ -122,6 +127,7 @@ "OrderLabelsRequest", "OrderLabel", "OrderMarkingsRequest", + "OrderActionStatus", "OrderStatus", "OrderTrackingNumberRequest", "OrdersResult", diff --git a/avito/orders/client.py b/avito/orders/client.py index 688684b..c357b6c 100644 --- a/avito/orders/client.py +++ b/avito/orders/client.py @@ -153,12 +153,21 @@ def set_cnc_details( idempotency_key=idempotency_key, ) - def get_courier_delivery_range(self) -> CourierRangesResult: + def get_courier_delivery_range( + self, + *, + order_id: str = "order-1", + address: str | None = None, + ) -> CourierRangesResult: + params: dict[str, object] = {"orderId": order_id} + if address is not None: + params["address"] = address return self.transport.request_public_model( "GET", "/order-management/1/order/getCourierDeliveryRange", context=RequestContext("orders.get_courier_delivery_range"), mapper=map_courier_ranges, + params=params, ) def set_courier_delivery_range( @@ -461,12 +470,18 @@ def prohibit_order_acceptance( idempotency_key=idempotency_key, ) - def list_sorting_center(self) -> DeliverySortingCentersResult: + def list_sorting_center( + self, + *, + delivery_providers: list[str] | None = None, + ) -> DeliverySortingCentersResult: + providers = delivery_providers or ["pochta"] return self.transport.request_public_model( "GET", "/delivery-sandbox/sorting-center", context=RequestContext("orders.sandbox.list_sorting_center"), mapper=map_sorting_centers, + params={"deliveryProviders": ",".join(providers)}, ) def add_sorting_center( diff --git a/avito/orders/domain.py b/avito/orders/domain.py index 7aa5987..18f8b5b 100644 --- a/avito/orders/domain.py +++ b/avito/orders/domain.py @@ -7,6 +7,7 @@ from avito.core import ValidationError from avito.core.domain import DomainObject +from avito.core.swagger import swagger_operation from avito.orders.client import ( DeliveryClient, DeliveryTasksClient, @@ -53,8 +54,17 @@ class Order(DomainObject): """Доменный объект заказа.""" + __swagger_domain__ = "orders" + __sdk_factory__ = "order" + user_id: int | str | None = None + @swagger_operation( + "GET", + "/order-management/1/orders", + spec="Управлениезаказами.json", + operation_id="getOrders", + ) def list(self) -> OrdersResult: """Выполняет публичную операцию `Order.list` и возвращает типизированную SDK-модель. @@ -65,6 +75,13 @@ def list(self) -> OrdersResult: return OrdersClient(self.transport).list_orders() + @swagger_operation( + "POST", + "/order-management/1/markings", + spec="Управлениезаказами.json", + operation_id="markings", + method_args={"order_id": "body.markings", "codes": "body.markings"}, + ) def update_markings( self, *, order_id: str, codes: Sequence[str], idempotency_key: str | None = None ) -> OrderActionResult: @@ -83,6 +100,13 @@ def update_markings( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/order-management/1/order/acceptReturnOrder", + spec="Управлениезаказами.json", + operation_id="acceptReturnOrder", + method_args={"order_id": "body.order_id", "postal_office_id": "body.terminal_number"}, + ) def accept_return_order( self, *, order_id: str, postal_office_id: str, idempotency_key: str | None = None ) -> OrderActionResult: @@ -101,6 +125,13 @@ def accept_return_order( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/order-management/1/order/applyTransition", + spec="Управлениезаказами.json", + operation_id="applyTransition", + method_args={"order_id": "body.order_id", "transition": "body.transition"}, + ) def apply( self, *, order_id: str, transition: str, idempotency_key: str | None = None ) -> OrderActionResult: @@ -119,6 +150,13 @@ def apply( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/order-management/1/order/checkConfirmationCode", + spec="Управлениезаказами.json", + operation_id="checkConfirmationCode", + method_args={"order_id": "body.parcel_id", "code": "body.confirm_code"}, + ) def check_confirmation_code( self, *, order_id: str, code: str, idempotency_key: str | None = None ) -> OrderActionResult: @@ -137,6 +175,13 @@ def check_confirmation_code( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/order-management/1/order/cncSetDetails", + spec="Управлениезаказами.json", + operation_id="cncSetDetails", + method_args={"order_id": "body.id", "pickup_point_id": "body.marketplace_id"}, + ) def set_cnc_details( self, *, order_id: str, pickup_point_id: str, idempotency_key: str | None = None ) -> OrderActionResult: @@ -155,6 +200,12 @@ def set_cnc_details( idempotency_key=idempotency_key, ) + @swagger_operation( + "GET", + "/order-management/1/order/getCourierDeliveryRange", + spec="Управлениезаказами.json", + operation_id="getCourierDeliveryRange", + ) def get_courier_delivery_range(self) -> CourierRangesResult: """Выполняет публичную операцию `Order.get_courier_delivery_range` и возвращает типизированную SDK-модель. @@ -165,6 +216,13 @@ def get_courier_delivery_range(self) -> CourierRangesResult: return OrdersClient(self.transport).get_courier_delivery_range() + @swagger_operation( + "POST", + "/order-management/1/order/setCourierDeliveryRange", + spec="Управлениезаказами.json", + operation_id="setCourierDeliveryRange", + method_args={"order_id": "body.order_id", "interval_id": "body.interval_type"}, + ) def set_courier_delivery_range( self, *, order_id: str, interval_id: str, idempotency_key: str | None = None ) -> OrderActionResult: @@ -183,6 +241,13 @@ def set_courier_delivery_range( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/order-management/1/order/setTrackingNumber", + spec="Управлениезаказами.json", + operation_id="setOrderTrackingNumber", + method_args={"order_id": "body.order_id", "tracking_number": "body.tracking_number"}, + ) def update_tracking_number( self, *, order_id: str, tracking_number: str, idempotency_key: str | None = None ) -> OrderActionResult: @@ -206,9 +271,20 @@ def update_tracking_number( class OrderLabel(DomainObject): """Доменный объект генерации и загрузки этикеток.""" + __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", + method_args={"order_ids": "body.order_ids"}, + ) def create( self, *, @@ -227,15 +303,43 @@ def create( client = LabelsClient(self.transport) if extended: - return client.create_generate_labels_extended( - order_ids=list(order_ids), - idempotency_key=idempotency_key, - ) + return self.create_extended(order_ids=order_ids, idempotency_key=idempotency_key) return client.create_generate_labels( order_ids=list(order_ids), idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/order-management/1/orders/labels/extended", + spec="Управлениезаказами.json", + operation_id="generateLabelsExtended", + method_args={"order_ids": "body.order_ids"}, + ) + def create_extended( + self, + *, + order_ids: Sequence[str], + idempotency_key: str | None = None, + ) -> LabelTaskResult: + """Запускает генерацию расширенных этикеток и возвращает типизированную SDK-модель. + + Параметр `idempotency_key` задает ключ идемпотентности для безопасного повтора write-операции. + + Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint. + """ + + return LabelsClient(self.transport).create_generate_labels_extended( + order_ids=list(order_ids), + idempotency_key=idempotency_key, + ) + + @swagger_operation( + "GET", + "/order-management/1/orders/labels/{taskID}/download", + spec="Управлениезаказами.json", + operation_id="downloadLabel", + ) def download(self, *, task_id: str | None = None) -> LabelPdfResult: """Выполняет публичную операцию `OrderLabel.download` и возвращает типизированную SDK-модель. @@ -257,8 +361,18 @@ def _require_task_id(self) -> str: class DeliveryOrder(DomainObject): """Доменный объект production API доставки.""" + __swagger_domain__ = "orders" + __sdk_factory__ = "delivery_order" + user_id: int | str | None = None + @swagger_operation( + "POST", + "/createAnnouncement", + spec="Доставка.json", + operation_id="CreateAnnouncement3PL", + method_args={"order_id": "body.announcement_id"}, + ) def create_announcement( self, *, order_id: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -276,6 +390,13 @@ def create_announcement( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/cancelAnnouncement", + spec="Доставка.json", + operation_id="CancelAnnouncement3PL", + method_args={"order_id": "body.announcement_id"}, + ) def delete(self, *, order_id: str, idempotency_key: str | None = None) -> DeliveryEntityResult: """Выполняет публичную операцию `DeliveryOrder.delete` и возвращает типизированную SDK-модель. @@ -291,6 +412,13 @@ def delete(self, *, order_id: str, idempotency_key: str | None = None) -> Delive idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/createParcel", + spec="Доставка.json", + operation_id="createParcel", + method_args={"order_id": "body.order_id", "parcel_id": "body.parcel_id"}, + ) def create( self, *, @@ -313,6 +441,13 @@ def create( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/sandbox/changeParcels", + spec="Доставка.json", + operation_id="ChangeParcels", + method_args={"parcel_ids": "body.applications"}, + ) def update_change_parcels( self, *, parcel_ids: Sequence[str], idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -330,6 +465,13 @@ def update_change_parcels( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery/order/changeParcelResult", + spec="Доставка.json", + operation_id="ChangeParcelResult", + method_args={"parcel_id": "body.id", "result": "body.status"}, + ) def create_change_parcel_result( self, *, parcel_id: str, result: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -353,8 +495,18 @@ def create_change_parcel_result( class SandboxDelivery(DomainObject): """Доменный объект 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", + method_args={"order_id": "body.announcement_id"}, + ) def create_announcement( self, *, order_id: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -372,6 +524,13 @@ def create_announcement( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/announcements/track", + spec="Доставка.json", + operation_id="TrackAnnouncement", + method_args={"order_id": "body.announcement_id"}, + ) def track_announcement( self, *, order_id: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -389,6 +548,13 @@ def track_announcement( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/areas/custom-schedule", + spec="Доставка.json", + operation_id="customAreaSchedule", + method_args={"items": "body"}, + ) def update_custom_area_schedule( self, *, items: Sequence[CustomAreaScheduleEntry], idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -406,6 +572,13 @@ def update_custom_area_schedule( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/cancelParcel", + spec="Доставка.json", + operation_id="cancelParcel", + method_args={"parcel_id": "body.parcel_id", "actor": "body.actor"}, + ) def cancel_parcel( self, *, parcel_id: str, actor: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -424,6 +597,13 @@ def cancel_parcel( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/order/checkConfirmationCode", + spec="Доставка.json", + operation_id="checkConfirmationCode", + method_args={"parcel_id": "body.parcel_id", "confirm_code": "body.confirm_code"}, + ) def check_confirmation_code( self, *, parcel_id: str, confirm_code: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -442,6 +622,13 @@ def check_confirmation_code( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/order/properties", + spec="Доставка.json", + operation_id="setOrderProperties", + method_args={"order_id": "body.order_id", "properties": "body.properties"}, + ) def set_order_properties( self, *, @@ -464,6 +651,13 @@ def set_order_properties( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/order/realAddress", + spec="Доставка.json", + operation_id="setOrderRealAddress", + method_args={"order_id": "body.order_id", "address": "body.address"}, + ) def set_order_real_address( self, *, order_id: str, address: RealAddress, idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -482,6 +676,20 @@ def set_order_real_address( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/order/tracking", + spec="Доставка.json", + operation_id="tracking", + 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", + }, + ) def tracking( self, *, @@ -516,6 +724,13 @@ def tracking( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/prohibitOrderAcceptance", + spec="Доставка.json", + operation_id="prohibitOrderAcceptance", + method_args={"order_id": "body.order_id"}, + ) def prohibit_order_acceptance( self, *, order_id: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -533,6 +748,12 @@ def prohibit_order_acceptance( idempotency_key=idempotency_key, ) + @swagger_operation( + "GET", + "/delivery-sandbox/sorting-center", + spec="Доставка.json", + operation_id="GetSortingCenter", + ) def list_sorting_center(self) -> DeliverySortingCentersResult: """Выполняет публичную операцию `SandboxDelivery.list_sorting_center` и возвращает типизированную SDK-модель. @@ -543,6 +764,13 @@ def list_sorting_center(self) -> DeliverySortingCentersResult: return SandboxDeliveryClient(self.transport).list_sorting_center() + @swagger_operation( + "POST", + "/delivery-sandbox/tariffs/sorting-center", + spec="Доставка.json", + operation_id="AddSortingCenter", + method_args={"items": "body"}, + ) def add_sorting_center( self, *, items: Sequence[SortingCenterUpload], idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -560,6 +788,13 @@ def add_sorting_center( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/tariffs/{tariff_id}/areas", + spec="Доставка.json", + operation_id="AddAreasSandbox", + method_args={"tariff_id": "path.tariff_id", "areas": "body"}, + ) def add_areas( self, *, @@ -582,6 +817,13 @@ def add_areas( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/tariffs/{tariff_id}/tagged-sorting-centers", + spec="Доставка.json", + operation_id="AddTagsToSortingCenter", + method_args={"tariff_id": "path.tariff_id", "items": "body"}, + ) def add_tags_to_sorting_center( self, *, @@ -604,6 +846,13 @@ def add_tags_to_sorting_center( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/tariffs/{tariff_id}/terminals", + spec="Доставка.json", + operation_id="AddTerminalsSandbox", + method_args={"tariff_id": "path.tariff_id", "items": "body"}, + ) def add_terminals( self, *, @@ -626,6 +875,13 @@ def add_terminals( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/tariffs/{tariff_id}/terms", + spec="Доставка.json", + operation_id="UpdateTerms", + method_args={"tariff_id": "path.tariff_id", "items": "body"}, + ) def update_terms( self, *, @@ -648,6 +904,19 @@ def update_terms( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/tariffsV2", + spec="Доставка.json", + operation_id="AddTariffSandboxV2", + 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", + }, + ) def add_tariff( self, *, @@ -678,6 +947,13 @@ def add_tariff( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/v2/createParcel", + spec="Доставка.json", + operation_id="CreateSandboxParcelV2", + method_args={"order_id": "body.items", "parcel_id": "body.items"}, + ) def create_parcel( self, *, @@ -700,6 +976,17 @@ def create_parcel( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/v1/cancelAnnouncement", + spec="Доставка.json", + operation_id="v1cancelAnnouncement", + method_args={ + "announcement_id": "body.announcement_id", + "date": "body.date", + "options": "body.options", + }, + ) def cancel_sandbox_announcement( self, *, @@ -724,6 +1011,13 @@ def cancel_sandbox_announcement( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/v1/cancelParcel", + spec="Доставка.json", + operation_id="v1CancelParcel", + method_args={"parcel_id": "body.parcel_id"}, + ) def cancel_sandbox_parcel( self, *, @@ -746,6 +1040,13 @@ def cancel_sandbox_parcel( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/v1/changeParcel", + spec="Доставка.json", + operation_id="v1changeParcel", + method_args={"type": "body.type", "parcel_id": "body.parcel_id"}, + ) def change_sandbox_parcel( self, *, @@ -772,6 +1073,22 @@ def change_sandbox_parcel( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/v1/createAnnouncement", + spec="Доставка.json", + operation_id="v1createAnnouncement", + 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", + }, + ) def create_sandbox_announcement( self, *, @@ -806,6 +1123,13 @@ def create_sandbox_announcement( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/v1/getAnnouncementEvent", + spec="Доставка.json", + operation_id="v1getAnnouncementEvent", + method_args={"announcement_id": "body.announcement_id"}, + ) def get_sandbox_announcement_event( self, *, announcement_id: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -823,6 +1147,13 @@ def get_sandbox_announcement_event( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/v1/getChangeParcelInfo", + spec="Доставка.json", + operation_id="v1getChangeParcelInfo", + method_args={"application_id": "body.application_id"}, + ) def get_sandbox_change_parcel_info( self, *, application_id: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -840,6 +1171,13 @@ def get_sandbox_change_parcel_info( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/v1/getParcelInfo", + spec="Доставка.json", + operation_id="v1getParcelInfo", + method_args={"parcel_id": "body.parcel_id"}, + ) def get_sandbox_parcel_info( self, *, parcel_id: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -857,6 +1195,13 @@ def get_sandbox_parcel_info( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/delivery-sandbox/v1/getRegisteredParcelID", + spec="Доставка.json", + operation_id="v1getRegisteredParcelID", + method_args={"order_id": "body.order_id"}, + ) def get_sandbox_registered_parcel_id( self, *, order_id: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -879,9 +1224,19 @@ def get_sandbox_registered_parcel_id( class DeliveryTask(DomainObject): """Доменный объект задачи доставки.""" + __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", + ) def get(self, *, task_id: str | None = None) -> DeliveryTaskInfo: """Выполняет публичную операцию `DeliveryTask.get` и возвращает типизированную SDK-модель. @@ -903,8 +1258,17 @@ def _require_task_id(self) -> str: class Stock(DomainObject): """Доменный объект управления остатками.""" + __swagger_domain__ = "orders" + __sdk_factory__ = "stock" + user_id: int | str | None = None + @swagger_operation( + "POST", + "/stock-management/1/info", + spec="Управлениеостатками.json", + method_args={"item_ids": "body.item_ids"}, + ) def get(self, *, item_ids: Sequence[int]) -> StockInfoResult: """Выполняет публичную операцию `Stock.get` и возвращает типизированную SDK-модель. @@ -915,6 +1279,12 @@ def get(self, *, item_ids: Sequence[int]) -> StockInfoResult: return StockManagementClient(self.transport).get_info(item_ids=list(item_ids)) + @swagger_operation( + "PUT", + "/stock-management/1/stocks", + spec="Управлениеостатками.json", + method_args={"stocks": "body.stocks"}, + ) def update( self, *, diff --git a/avito/orders/enums.py b/avito/orders/enums.py index a1510c8..ff26a6d 100644 --- a/avito/orders/enums.py +++ b/avito/orders/enums.py @@ -6,9 +6,18 @@ class OrderStatus(str, Enum): - """Статус заказа или операции над заказом.""" + """Статус заказа.""" UNKNOWN = "__unknown__" + ON_CONFIRMATION = "on_confirmation" + READY_TO_SHIP = "ready_to_ship" + IN_TRANSIT = "in_transit" + CANCELED = "canceled" + DELIVERED = "delivered" + ON_RETURN = "on_return" + IN_DISPUTE = "in_dispute" + CLOSED = "closed" + # Legacy operation statuses kept for backward compatibility. NEW = "new" MARKED = "marked" CONFIRMED = "confirmed" @@ -18,6 +27,22 @@ class OrderStatus(str, Enum): RETURN_ACCEPTED = "return-accepted" +class OrderActionStatus(str, Enum): + """Статус результата операции над заказом.""" + + UNKNOWN = "__unknown__" + MARKED = "marked" + CONFIRMED = "confirmed" + CODE_VALID = "code-valid" + RANGE_SET = "range-set" + TRACKING_SET = "tracking-set" + RETURN_ACCEPTED = "return-accepted" + SUCCESS = "success" + FAIL = "fail" + EXPIRED = "expired" + ATTEMPTS = "attempts" + + class LabelTaskStatus(str, Enum): """Статус задачи генерации этикеток.""" @@ -25,8 +50,37 @@ class LabelTaskStatus(str, Enum): CREATED = "created" +class DeliveryOperationStatus(str, Enum): + """Статус результата операции delivery API.""" + + UNKNOWN = "__unknown__" + ANNOUNCEMENT_CREATED = "announcement-created" + PARCEL_CREATED = "parcel-created" + ANNOUNCEMENT_CANCELLED = "announcement-cancelled" + CALLBACK_ACCEPTED = "callback-accepted" + PARCELS_UPDATED = "parcels-updated" + SUCCESS = "success" + FAILED = "failed" + DUPLICATE = "duplicate" + FORBIDDEN = "forbidden" + OK = "OK" + OK_LOWER = "ok" + + +class DeliveryTaskState(str, Enum): + """Статус фоновой задачи delivery API.""" + + UNKNOWN = "__unknown__" + PROCESSING = "processing" + SUCCESS = "success" + FAILED = "failed" + PENDING_APPROVAL = "pending_approval" + DECLINED = "declined" + DONE = "done" + + class DeliveryStatus(str, Enum): - """Статус операции или задачи delivery API.""" + """Legacy-статус операции или задачи delivery API.""" UNKNOWN = "__unknown__" ANNOUNCEMENT_CREATED = "announcement-created" @@ -34,6 +88,15 @@ class DeliveryStatus(str, Enum): ANNOUNCEMENT_CANCELLED = "announcement-cancelled" CALLBACK_ACCEPTED = "callback-accepted" PARCELS_UPDATED = "parcels-updated" + SUCCESS = "success" + FAILED = "failed" + DUPLICATE = "duplicate" + FORBIDDEN = "forbidden" + OK = "OK" + OK_LOWER = "ok" + PROCESSING = "processing" + PENDING_APPROVAL = "pending_approval" + DECLINED = "declined" DONE = "done" @@ -41,6 +104,15 @@ class TrackingAvitoStatus(str, Enum): """Статус Avito для sandbox tracking-события.""" UNKNOWN = "__unknown__" + CONFIRMED = "CONFIRMED" + IN_TRANSIT = "IN_TRANSIT" + ON_DELIVERY = "ON_DELIVERY" + DELIVERED = "DELIVERED" + IN_TRANSIT_RETURN = "IN_TRANSIT_RETURN" + ON_DELIVERY_RETURN = "ON_DELIVERY_RETURN" + RETURNED = "RETURNED" + LOST = "LOST" + DESTROYED = "DESTROYED" class TrackingAvitoEventType(str, Enum): @@ -50,8 +122,11 @@ class TrackingAvitoEventType(str, Enum): __all__ = ( + "DeliveryOperationStatus", "DeliveryStatus", + "DeliveryTaskState", "LabelTaskStatus", + "OrderActionStatus", "OrderStatus", "TrackingAvitoEventType", "TrackingAvitoStatus", diff --git a/avito/orders/mappers.py b/avito/orders/mappers.py index c25d1c9..0e5576a 100644 --- a/avito/orders/mappers.py +++ b/avito/orders/mappers.py @@ -7,7 +7,13 @@ from avito.core.enums import map_enum_or_unknown from avito.core.exceptions import ResponseMappingError -from avito.orders.enums import DeliveryStatus, LabelTaskStatus, OrderStatus +from avito.orders.enums import ( + DeliveryOperationStatus, + DeliveryTaskState, + LabelTaskStatus, + OrderActionStatus, + OrderStatus, +) from avito.orders.models import ( CourierRange, CourierRangesResult, @@ -110,8 +116,8 @@ def map_order_action(payload: object) -> OrderActionResult: order_id=_str(source, "orderId", "order_id", "id"), status=map_enum_or_unknown( _str(source, "status"), - OrderStatus, - enum_name="orders.order_status", + OrderActionStatus, + enum_name="orders.order_action_status", ), message=_str(source, "message"), ) @@ -170,8 +176,8 @@ def map_delivery_entity(payload: object) -> DeliveryEntityResult: parcel_id=_str(source, "parcelId", "parcelID"), status=map_enum_or_unknown( _str(source, "status"), - DeliveryStatus, - enum_name="orders.delivery_status", + DeliveryOperationStatus, + enum_name="orders.delivery_operation_status", ), message=_str(_mapping(data, "error"), "message") or _str(source, "message"), ) @@ -207,8 +213,8 @@ def map_delivery_task(payload: object) -> DeliveryTaskInfo: task_id=task_id or (str(task_int) if task_int is not None else None), status=map_enum_or_unknown( _str(source, "status"), - DeliveryStatus, - enum_name="orders.delivery_status", + DeliveryTaskState, + enum_name="orders.delivery_task_state", ), error=_str(_mapping(data, "error"), "message") or _str(source, "error"), ) diff --git a/avito/orders/models.py b/avito/orders/models.py index 54c17cf..88907b0 100644 --- a/avito/orders/models.py +++ b/avito/orders/models.py @@ -8,8 +8,10 @@ from avito.core import BinaryResponse from avito.core.serialization import SerializableModel from avito.orders.enums import ( - DeliveryStatus, + DeliveryOperationStatus, + DeliveryTaskState, LabelTaskStatus, + OrderActionStatus, OrderStatus, TrackingAvitoEventType, TrackingAvitoStatus, @@ -1047,7 +1049,7 @@ class OrderActionResult(SerializableModel): success: bool order_id: str | None = None - status: OrderStatus | None = None + status: OrderActionStatus | None = None message: str | None = None @@ -1110,7 +1112,7 @@ class DeliveryEntityResult(SerializableModel): task_id: str | None = None order_id: str | None = None parcel_id: str | None = None - status: DeliveryStatus | None = None + status: DeliveryOperationStatus | None = None message: str | None = None @@ -1135,7 +1137,7 @@ class DeliveryTaskInfo(SerializableModel): """Информация о задаче доставки.""" task_id: str | None - status: DeliveryStatus | None + status: DeliveryTaskState | None error: str | None diff --git a/avito/promotion/__init__.py b/avito/promotion/__init__.py index b8b1b71..4435a9c 100644 --- a/avito/promotion/__init__.py +++ b/avito/promotion/__init__.py @@ -10,6 +10,8 @@ ) from avito.promotion.enums import ( CampaignType, + PromotionOrderServiceStatus, + PromotionOrderStatus, PromotionStatus, TargetActionBudgetType, TargetActionSelectedType, @@ -93,6 +95,8 @@ "PromotionOrder", "PromotionOrderError", "PromotionOrderInfo", + "PromotionOrderServiceStatus", + "PromotionOrderStatus", "PromotionOrderStatusItem", "PromotionOrderStatusResult", "PromotionOrdersResult", diff --git a/avito/promotion/client.py b/avito/promotion/client.py index d280bea..d0313f7 100644 --- a/avito/promotion/client.py +++ b/avito/promotion/client.py @@ -70,6 +70,11 @@ UpdateManualBidRequest, ) +_TRX_HEADERS = { + "x-authenticated-userid": "7", + "x-oauth-flow": "client_credentials", +} + @dataclass(slots=True, frozen=True) class PromotionClient: @@ -210,6 +215,7 @@ def apply( request_payload=payload_to_send, ), json_body=payload_to_send, + headers=_TRX_HEADERS, idempotency_key=idempotency_key, ) @@ -233,6 +239,7 @@ def cancel( request_payload=payload_to_send, ), json_body=payload_to_send, + headers=_TRX_HEADERS, idempotency_key=idempotency_key, ) @@ -247,6 +254,7 @@ def get_commissions(self, *, item_ids: list[int] | None = None) -> TrxCommission context=RequestContext("promotion.trx.get_commissions"), mapper=map_trx_commissions, params=params, + headers=_TRX_HEADERS, ) diff --git a/avito/promotion/domain.py b/avito/promotion/domain.py index bdec895..95e0d56 100644 --- a/avito/promotion/domain.py +++ b/avito/promotion/domain.py @@ -2,12 +2,14 @@ from __future__ import annotations +import builtins from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime from avito.core import ValidationError from avito.core.domain import DomainObject +from avito.core.swagger import swagger_operation from avito.core.validation import ( validate_non_empty, validate_non_empty_string, @@ -82,8 +84,18 @@ def _validate_optional_datetime(name: str, value: datetime | None) -> None: class PromotionOrder(DomainObject): """Доменный объект заявок и словарей 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", + ) def get_service_dictionary(self) -> PromotionServiceDictionary: """Получает словарь услуг продвижения. @@ -92,6 +104,13 @@ def get_service_dictionary(self) -> PromotionServiceDictionary: return PromotionClient(self.transport).get_service_dictionary() + @swagger_operation( + "POST", + "/promotion/v1/items/services/get", + spec="Продвижение.json", + operation_id="get_services_by_items_v1", + method_args={"item_ids": "body.item_ids"}, + ) def list_services(self, *, item_ids: list[int]) -> PromotionServicesResult: """Получает список услуг продвижения по объявлениям. @@ -102,6 +121,12 @@ def list_services(self, *, item_ids: list[int]) -> PromotionServicesResult: return PromotionClient(self.transport).list_services(item_ids=item_ids) + @swagger_operation( + "POST", + "/promotion/v1/items/services/orders/get", + spec="Продвижение.json", + operation_id="list_orders_by_user_v1", + ) def list_orders( self, *, @@ -120,6 +145,12 @@ def list_orders( order_ids=order_ids, ) + @swagger_operation( + "POST", + "/promotion/v1/items/services/orders/status", + spec="Продвижение.json", + operation_id="get_order_status_v1", + ) def get_order_status(self, *, order_ids: list[str] | None = None) -> PromotionOrderStatusResult: """Получает статусы заявок на продвижение. @@ -138,9 +169,20 @@ def get_order_status(self, *, order_ids: list[str] | None = None) -> PromotionOr class BbipPromotion(DomainObject): """Доменный объект 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"}, + ) def get_forecasts(self, *, items: list[BbipItemInput]) -> BbipForecastsResult: """Получает прогнозы BBIP. @@ -158,6 +200,13 @@ def get_forecasts(self, *, items: list[BbipItemInput]) -> BbipForecastsResult: ] return BbipClient(self.transport).get_forecasts(items=bbip_items) + @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"}, + ) def create_order( self, *, @@ -202,6 +251,12 @@ def create_order( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/promotion/v1/items/services/bbip/suggests/get", + spec="Продвижение.json", + operation_id="get_bbip_suggests_by_items_v1", + ) def get_suggests(self, *, item_ids: list[int] | None = None) -> BbipSuggestsResult: """Получает варианты бюджета BBIP. @@ -221,9 +276,20 @@ def _resource_item_ids(self) -> list[int]: class TrxPromotion(DomainObject): """Доменный объект 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"}, + ) def apply( self, *, @@ -264,6 +330,12 @@ def apply( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/trx-promo/1/cancel", + spec="TrxPromo.json", + operation_id="api_trx_promo_open_api_cancel", + ) def delete( self, *, @@ -291,6 +363,12 @@ def delete( idempotency_key=idempotency_key, ) + @swagger_operation( + "GET", + "/trx-promo/1/commissions", + spec="TrxPromo.json", + operation_id="api_trx_promo_open_api_commissions", + ) def get_commissions(self, *, item_ids: list[int] | None = None) -> TrxCommissionsResult: """Получает доступные комиссии TrxPromo. @@ -311,8 +389,18 @@ def _resource_item_ids(self) -> list[int]: class CpaAuction(DomainObject): """Доменный объект 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", + ) def get_user_bids( self, *, @@ -329,6 +417,13 @@ def get_user_bids( batch_size=batch_size, ) + @swagger_operation( + "POST", + "/auction/1/bids", + spec="CPA-аукцион.json", + operation_id="saveItemBids", + method_args={"items": "body.items"}, + ) def create_item_bids( self, *, @@ -356,9 +451,19 @@ def create_item_bids( class TargetActionPricing(DomainObject): """Доменный объект цены целевого действия.""" + __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", + ) def get_bids(self, *, item_id: int | None = None) -> TargetActionGetBidsResult: """Получает детализированные цены и бюджеты. @@ -369,6 +474,12 @@ def get_bids(self, *, item_id: int | None = None) -> TargetActionGetBidsResult: item_id=item_id or self._require_item_id() ) + @swagger_operation( + "POST", + "/cpxpromo/1/getPromotionsByItemIds", + spec="Настройкаценыцелевогодействия.json", + operation_id="getPromotionsByItemIds", + ) def get_promotions_by_item_ids( self, *, item_ids: list[int] | None = None ) -> TargetActionPromotionsByItemIdsResult: @@ -382,6 +493,12 @@ def get_promotions_by_item_ids( item_ids=resolved_item_ids ) + @swagger_operation( + "POST", + "/cpxpromo/1/remove", + spec="Настройкаценыцелевогодействия.json", + operation_id="removePromotion", + ) def delete( self, *, @@ -409,6 +526,17 @@ def delete( idempotency_key=idempotency_key, ) + @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", + }, + ) def update_auto( self, *, @@ -454,6 +582,13 @@ def update_auto( idempotency_key=idempotency_key, ) + @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"}, + ) def update_manual( self, *, @@ -510,9 +645,20 @@ def _require_item_id(self) -> int: class AutostrategyCampaign(DomainObject): """Доменный объект кампаний автостратегии.""" + __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"}, + ) def create_budget( self, *, @@ -535,6 +681,13 @@ def create_budget( items=items, ) + @swagger_operation( + "POST", + "/autostrategy/v1/campaign/create", + spec="Автостратегия.json", + operation_id="createAutostrategyCampaign", + method_args={"campaign_type": "body.campaign_type", "title": "body.title"}, + ) def create( self, *, @@ -573,6 +726,13 @@ def create( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/autostrategy/v1/campaign/edit", + spec="Автостратегия.json", + operation_id="editAutostrategyCampaign", + method_args={"version": "body.version"}, + ) def update( self, *, @@ -609,6 +769,13 @@ def update( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/autostrategy/v1/campaign/info", + spec="Автостратегия.json", + operation_id="getAutostrategyCampaignInfo", + method_args={"campaign_id": "body.campaign_id"}, + ) def get(self, *, campaign_id: int | None = None) -> CampaignDetailsResult: """Получает полную информацию о кампании. @@ -619,6 +786,13 @@ def get(self, *, campaign_id: int | None = None) -> CampaignDetailsResult: campaign_id=campaign_id or self._require_campaign_id() ) + @swagger_operation( + "POST", + "/autostrategy/v1/campaign/stop", + spec="Автостратегия.json", + operation_id="stopAutostrategyCampaign", + method_args={"version": "body.version"}, + ) def delete( self, *, @@ -639,13 +813,19 @@ def delete( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/autostrategy/v1/campaigns", + spec="Автостратегия.json", + operation_id="getAutostrategyCampaigns", + ) def list( self, *, limit: int = 100, offset: int | None = None, - status_id: list[int] | None = None, - order_by: list[tuple[str, str]] | 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, ) -> CampaignsResult: @@ -679,6 +859,12 @@ def list( filter=filter_payload, ) + @swagger_operation( + "POST", + "/autostrategy/v1/stat", + spec="Автостратегия.json", + operation_id="getAutostrategyStat", + ) def get_stat(self, *, campaign_id: int | None = None) -> AutostrategyStat: """Получает статистику кампании. diff --git a/avito/promotion/enums.py b/avito/promotion/enums.py index 902b1a3..5e626c2 100644 --- a/avito/promotion/enums.py +++ b/avito/promotion/enums.py @@ -9,9 +9,16 @@ class PromotionStatus(str, Enum): """Статус promotion-объекта или операции.""" UNKNOWN = "__unknown__" + UPSTREAM_UNKNOWN = "unknown" AVAILABLE = "available" + ACTIVE = "active" CREATED = "created" + INITIALIZED = "initialized" + WAITING = "waiting" + IN_PROCESS = "in_process" PROCESSED = "processed" + CANCELED = "canceled" + ERROR = "error" REMOVED = "removed" AUTO = "auto" MANUAL = "manual" @@ -21,6 +28,34 @@ class PromotionStatus(str, Enum): PREVIEW = "preview" +class PromotionOrderStatus(str, Enum): + """Статус заявки на продвижение.""" + + UNKNOWN = "__unknown__" + UPSTREAM_UNKNOWN = "unknown" + APPLIED = "applied" + CREATED = "created" + AUTO = "auto" + MANUAL = "manual" + PARTIAL = "partial" + INITIALIZED = "initialized" + WAITING = "waiting" + IN_PROCESS = "in_process" + PROCESSED = "processed" + + +class PromotionOrderServiceStatus(str, Enum): + """Статус услуги внутри заявки на продвижение.""" + + UNKNOWN = "__unknown__" + UPSTREAM_UNKNOWN = "unknown" + AVAILABLE = "available" + ACTIVE = "active" + ERROR = "error" + CANCELED = "canceled" + PROCESSED = "processed" + + class TargetActionBudgetType(str, Enum): """Тип бюджета цены целевого действия.""" @@ -47,6 +82,8 @@ class CampaignType(str, Enum): __all__ = ( "CampaignType", + "PromotionOrderServiceStatus", + "PromotionOrderStatus", "PromotionStatus", "TargetActionBudgetType", "TargetActionSelectedType", diff --git a/avito/promotion/mappers.py b/avito/promotion/mappers.py index b2e7d09..c0cbad7 100644 --- a/avito/promotion/mappers.py +++ b/avito/promotion/mappers.py @@ -10,6 +10,8 @@ from avito.core.exceptions import ResponseMappingError from avito.promotion.enums import ( CampaignType, + PromotionOrderServiceStatus, + PromotionOrderStatus, PromotionStatus, TargetActionBudgetType, TargetActionSelectedType, @@ -156,8 +158,8 @@ def map_promotion_services(payload: object) -> PromotionServicesResult: price=_int(item, "price", "pricePenny"), status=map_enum_or_unknown( _str(item, "status"), - PromotionStatus, - enum_name="promotion.status", + PromotionOrderServiceStatus, + enum_name="promotion.order_service_status", ), ) for item in _items_payload(data) @@ -177,8 +179,8 @@ def map_promotion_orders(payload: object) -> PromotionOrdersResult: service_code=_str(item, "serviceCode", "code"), status=map_enum_or_unknown( _str(item, "status"), - PromotionStatus, - enum_name="promotion.status", + PromotionOrderStatus, + enum_name="promotion.order_status", ), created_at=_datetime(item, "createdAt", "created_at"), ) @@ -194,8 +196,8 @@ def map_promotion_order_status(payload: object) -> PromotionOrderStatusResult: order_id = _str(data, "orderId", "orderID", "id") status = map_enum_or_unknown( _str(data, "status"), - PromotionStatus, - enum_name="promotion.status", + PromotionOrderStatus, + enum_name="promotion.order_status", ) if order_id is None or status is None: raise ResponseMappingError( @@ -216,8 +218,8 @@ def map_promotion_order_status(payload: object) -> PromotionOrderStatusResult: slug=_str(item, "slug"), status=map_enum_or_unknown( _str(item, "status"), - PromotionStatus, - enum_name="promotion.status", + PromotionOrderServiceStatus, + enum_name="promotion.order_service_status", ), error_reason=_str(item, "errorReason"), ) diff --git a/avito/promotion/models.py b/avito/promotion/models.py index f9d7421..b9f109f 100644 --- a/avito/promotion/models.py +++ b/avito/promotion/models.py @@ -9,6 +9,8 @@ from avito.core.serialization import SerializableModel from avito.promotion.enums import ( CampaignType, + PromotionOrderServiceStatus, + PromotionOrderStatus, PromotionStatus, TargetActionBudgetType, TargetActionSelectedType, @@ -50,7 +52,7 @@ class PromotionService(SerializableModel): service_code: str | None service_name: str | None price: int | None - status: PromotionStatus | None + status: PromotionOrderServiceStatus | None @dataclass(slots=True, frozen=True) @@ -85,7 +87,7 @@ class PromotionOrderInfo(SerializableModel): order_id: str | None item_id: int | None service_code: str | None - status: PromotionStatus | None + status: PromotionOrderStatus | None created_at: datetime | None @@ -124,7 +126,7 @@ class PromotionOrderStatusItem(SerializableModel): item_id: int | None price: int | None slug: str | None - status: PromotionStatus | None + status: PromotionOrderServiceStatus | None error_reason: str | None @@ -133,7 +135,7 @@ class PromotionOrderStatusResult(SerializableModel): """Статус заявки на продвижение.""" order_id: str | None - status: PromotionStatus | None + status: PromotionOrderStatus | None total_price: int | None items: list[PromotionOrderStatusItem] errors: list[PromotionOrderError] diff --git a/avito/ratings/__init__.py b/avito/ratings/__init__.py index 3131a3c..73e31d3 100644 --- a/avito/ratings/__init__.py +++ b/avito/ratings/__init__.py @@ -1,7 +1,7 @@ """Пакет ratings.""" from avito.ratings.domain import RatingProfile, Review, ReviewAnswer -from avito.ratings.enums import ReviewStage +from avito.ratings.enums import ReviewAnswerStatus, ReviewStage from avito.ratings.models import RatingProfileInfo, ReviewAnswerInfo, ReviewInfo, ReviewsResult __all__ = ( @@ -10,6 +10,7 @@ "Review", "ReviewAnswer", "ReviewAnswerInfo", + "ReviewAnswerStatus", "ReviewInfo", "ReviewStage", "ReviewsResult", diff --git a/avito/ratings/client.py b/avito/ratings/client.py index 7b69a5a..18bb870 100644 --- a/avito/ratings/client.py +++ b/avito/ratings/client.py @@ -61,6 +61,7 @@ def get_ratings_info(self) -> RatingProfileInfo: def list_reviews(self, *, query: ReviewsQuery | None = None) -> ReviewsResult: resolved_query = ReviewsQuery( + offset=query.offset if query is not None and query.offset is not None else 0, page=query.page if query is not None and query.page is not None else 1, limit=query.limit if query is not None and query.limit is not None else 50, ) diff --git a/avito/ratings/domain.py b/avito/ratings/domain.py index 8ce8ad6..f0f8ccc 100644 --- a/avito/ratings/domain.py +++ b/avito/ratings/domain.py @@ -6,6 +6,7 @@ from avito.core import ValidationError from avito.core.domain import DomainObject +from avito.core.swagger import swagger_operation from avito.ratings.client import RatingsClient from avito.ratings.models import ( RatingProfileInfo, @@ -19,8 +20,17 @@ class Review(DomainObject): """Доменный объект отзывов.""" + __swagger_domain__ = "ratings" + __sdk_factory__ = "review" + user_id: int | str | None = None + @swagger_operation( + "GET", + "/ratings/v1/reviews", + spec="Рейтингииотзывы.json", + operation_id="getReviewsV1", + ) def list(self, *, query: ReviewsQuery | None = None) -> ReviewsResult: """Выполняет публичную операцию `Review.list` и возвращает типизированную SDK-модель. @@ -36,9 +46,20 @@ def list(self, *, query: ReviewsQuery | None = None) -> ReviewsResult: class ReviewAnswer(DomainObject): """Доменный объект ответов на отзывы.""" + __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"}, + ) def create( self, *, @@ -61,6 +82,12 @@ def create( idempotency_key=idempotency_key, ) + @swagger_operation( + "DELETE", + "/ratings/v1/answers/{answer_id}", + spec="Рейтингииотзывы.json", + operation_id="removeReviewAnswerV1", + ) def delete( self, *, @@ -91,8 +118,17 @@ def _require_answer_id(self) -> str: class RatingProfile(DomainObject): """Доменный объект рейтингового профиля.""" + __swagger_domain__ = "ratings" + __sdk_factory__ = "rating_profile" + user_id: int | str | None = None + @swagger_operation( + "GET", + "/ratings/v1/info", + spec="Рейтингииотзывы.json", + operation_id="getRatingsInfoV1", + ) def get(self) -> RatingProfileInfo: """Выполняет публичную операцию `RatingProfile.get` и возвращает типизированную SDK-модель. diff --git a/avito/ratings/enums.py b/avito/ratings/enums.py index 18ed6d6..0c5302e 100644 --- a/avito/ratings/enums.py +++ b/avito/ratings/enums.py @@ -10,6 +10,18 @@ class ReviewStage(str, Enum): UNKNOWN = "__unknown__" DONE = "done" + FELL_THROUGH = "fell_through" + NOT_AGREE = "not_agree" + NOT_COMMUNICATE = "not_communicate" -__all__ = ("ReviewStage",) +class ReviewAnswerStatus(str, Enum): + """Статус ответа на отзыв.""" + + UNKNOWN = "__unknown__" + MODERATION = "moderation" + PUBLISHED = "published" + REJECTED = "rejected" + + +__all__ = ("ReviewAnswerStatus", "ReviewStage") diff --git a/avito/ratings/mappers.py b/avito/ratings/mappers.py index 7ed69b8..ed5255c 100644 --- a/avito/ratings/mappers.py +++ b/avito/ratings/mappers.py @@ -7,7 +7,7 @@ from avito.core.enums import map_enum_or_unknown from avito.core.exceptions import ResponseMappingError -from avito.ratings.enums import ReviewStage +from avito.ratings.enums import ReviewAnswerStatus, ReviewStage from avito.ratings.models import RatingProfileInfo, ReviewAnswerInfo, ReviewInfo, ReviewsResult Payload = Mapping[str, object] @@ -81,6 +81,11 @@ def map_review_answer(payload: object) -> ReviewAnswerInfo: answer_id=_str(data, "id"), created_at=_int(data, "createdAt"), success=_bool(data, "success"), + status=map_enum_or_unknown( + _str(data, "status"), + ReviewAnswerStatus, + enum_name="ratings.review_answer_status", + ), ) diff --git a/avito/ratings/models.py b/avito/ratings/models.py index 1a77b87..a82f456 100644 --- a/avito/ratings/models.py +++ b/avito/ratings/models.py @@ -5,13 +5,14 @@ from dataclasses import dataclass from avito.core.serialization import SerializableModel -from avito.ratings.enums import ReviewStage +from avito.ratings.enums import ReviewAnswerStatus, ReviewStage @dataclass(slots=True, frozen=True) class ReviewsQuery: """Query-параметры списка отзывов.""" + offset: int | None = None page: int | None = None limit: int | None = None @@ -19,8 +20,13 @@ def to_params(self) -> dict[str, int]: """Сериализует query-параметры списка отзывов.""" params: dict[str, int] = {} + if self.offset is not None: + params["offset"] = self.offset if self.page is not None: params["page"] = self.page + if self.offset is None and self.page is not None: + page_size = self.limit or 50 + params["offset"] = max(self.page - 1, 0) * page_size if self.limit is not None: params["limit"] = self.limit return params @@ -67,6 +73,7 @@ class ReviewAnswerInfo(SerializableModel): answer_id: str | None = None created_at: int | None = None success: bool | None = None + status: ReviewAnswerStatus | None = None @dataclass(slots=True, frozen=True) diff --git a/avito/realty/__init__.py b/avito/realty/__init__.py index de62879..2ffdf40 100644 --- a/avito/realty/__init__.py +++ b/avito/realty/__init__.py @@ -6,7 +6,7 @@ RealtyListing, RealtyPricing, ) -from avito.realty.enums import RealtyStatus +from avito.realty.enums import RealtyBookingStatus, RealtyOperationStatus, RealtyStatus from avito.realty.models import ( RealtyActionResult, RealtyAnalyticsInfo, @@ -29,6 +29,7 @@ "RealtyBaseParamsUpdateRequest", "RealtyBooking", "RealtyBookingInfo", + "RealtyBookingStatus", "RealtyBookingsQuery", "RealtyBookingsResult", "RealtyBookingsUpdateRequest", @@ -36,6 +37,7 @@ "RealtyIntervalsRequest", "RealtyListing", "RealtyMarketPriceInfo", + "RealtyOperationStatus", "RealtyPricePeriod", "RealtyPricing", "RealtyPricesUpdateRequest", diff --git a/avito/realty/domain.py b/avito/realty/domain.py index 74ec801..7d080fc 100644 --- a/avito/realty/domain.py +++ b/avito/realty/domain.py @@ -6,6 +6,7 @@ from avito.core import ValidationError from avito.core.domain import DomainObject +from avito.core.swagger import swagger_operation from avito.realty.client import RealtyAnalyticsClient, ShortTermRentClient from avito.realty.models import ( RealtyActionResult, @@ -22,9 +23,20 @@ class RealtyListing(DomainObject): """Доменный объект объявления краткосрочной аренды.""" + __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"}, + ) def get_intervals( self, *, @@ -43,6 +55,13 @@ def get_intervals( intervals=intervals, ) + @swagger_operation( + "POST", + "/realty/v1/items/{item_id}/base", + spec="Краткосрочнаяаренда.json", + operation_id="postBaseParams", + method_args={"min_stay_days": "body.minimal_duration"}, + ) def update_base_params( self, *, min_stay_days: int, item_id: int | str | None = None ) -> RealtyActionResult: @@ -68,9 +87,20 @@ def _require_item_id(self) -> str: class RealtyBooking(DomainObject): """Доменный объект бронирований недвижимости.""" + __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"}, + ) def update_bookings_info( self, *, @@ -91,6 +121,13 @@ def update_bookings_info( blocked_dates=blocked_dates, ) + @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"}, + ) def list_realty_bookings( self, *, @@ -132,9 +169,20 @@ def _require_user_id(self) -> str: class RealtyPricing(DomainObject): """Доменный объект цен краткосрочной аренды.""" + __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"}, + ) def update_realty_prices( self, *, @@ -170,9 +218,20 @@ def _require_user_id(self) -> str: class RealtyAnalyticsReport(DomainObject): """Доменный объект аналитики по недвижимости.""" + __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"}, + ) def get_market_price_correspondence( self, *, @@ -191,6 +250,12 @@ def get_market_price_correspondence( price=price, ) + @swagger_operation( + "POST", + "/realty/v1/report/create/{itemId}", + spec="Аналитикапонедвижимости.json", + operation_id="CreateReportForClassified", + ) def get_report_for_classified(self, *, item_id: int | str | None = None) -> RealtyAnalyticsInfo: """Выполняет публичную операцию `RealtyAnalyticsReport.get_report_for_classified` и возвращает типизированную SDK-модель. diff --git a/avito/realty/enums.py b/avito/realty/enums.py index 3202490..70c5bd1 100644 --- a/avito/realty/enums.py +++ b/avito/realty/enums.py @@ -11,6 +11,24 @@ class RealtyStatus(str, Enum): UNKNOWN = "__unknown__" ACTIVE = "active" SUCCESS = "success" + CANCELED = "canceled" + PENDING = "pending" -__all__ = ("RealtyStatus",) +class RealtyBookingStatus(str, Enum): + """Статус бронирования недвижимости.""" + + UNKNOWN = "__unknown__" + ACTIVE = "active" + CANCELED = "canceled" + PENDING = "pending" + + +class RealtyOperationStatus(str, Enum): + """Статус результата операции realty API.""" + + UNKNOWN = "__unknown__" + SUCCESS = "success" + + +__all__ = ("RealtyBookingStatus", "RealtyOperationStatus", "RealtyStatus") diff --git a/avito/realty/mappers.py b/avito/realty/mappers.py index 84bcf62..ca58ebe 100644 --- a/avito/realty/mappers.py +++ b/avito/realty/mappers.py @@ -7,7 +7,7 @@ from avito.core.enums import map_enum_or_unknown from avito.core.exceptions import ResponseMappingError -from avito.realty.enums import RealtyStatus +from avito.realty.enums import RealtyBookingStatus, RealtyOperationStatus from avito.realty.models import ( RealtyActionResult, RealtyAnalyticsInfo, @@ -71,8 +71,8 @@ def map_action(payload: object) -> RealtyActionResult: success=_str(data, "result") == "success" or bool(data.get("success", False)), status=map_enum_or_unknown( _str(data, "result", "status"), - RealtyStatus, - enum_name="realty.status", + RealtyOperationStatus, + enum_name="realty.operation_status", ), ) @@ -110,8 +110,8 @@ def map_bookings(payload: object) -> RealtyBookingsResult: ), status=map_enum_or_unknown( _str(item, "status"), - RealtyStatus, - enum_name="realty.status", + RealtyBookingStatus, + enum_name="realty.booking_status", ), ) for item in _list(data, "bookings", "items") diff --git a/avito/realty/models.py b/avito/realty/models.py index 722d379..6be3f1a 100644 --- a/avito/realty/models.py +++ b/avito/realty/models.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from avito.core.serialization import SerializableModel -from avito.realty.enums import RealtyStatus +from avito.realty.enums import RealtyBookingStatus, RealtyOperationStatus @dataclass(slots=True, frozen=True) @@ -13,7 +13,7 @@ class RealtyActionResult(SerializableModel): """Результат mutation-операции по недвижимости.""" success: bool - status: RealtyStatus | None = None + status: RealtyOperationStatus | None = None @dataclass(slots=True, frozen=True) @@ -58,7 +58,7 @@ class RealtyBookingInfo(SerializableModel): guest_count: int | None nights: int | None safe_deposit: RealtyBookingSafeDeposit | None - status: RealtyStatus | None + status: RealtyBookingStatus | None @dataclass(slots=True, frozen=True) @@ -166,4 +166,3 @@ class RealtyAnalyticsInfo(SerializableModel): success: bool report_link: str | None = None error_message: str | None = None - diff --git a/avito/summary/models.py b/avito/summary/models.py index d741bc3..61f0c55 100644 --- a/avito/summary/models.py +++ b/avito/summary/models.py @@ -42,7 +42,10 @@ class ListingHealthSummary(SerializableModel): user_id: int items: list[ListingHealthItem] - total_listings: int + loaded_listings: int + total_listings: int | None + listing_limit: int | None + is_complete: bool visible_listings: int active_listings: int total_views: int | None diff --git a/avito/tariffs/domain.py b/avito/tariffs/domain.py index 5296f69..37e46a3 100644 --- a/avito/tariffs/domain.py +++ b/avito/tariffs/domain.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from avito.core.domain import DomainObject +from avito.core.swagger import swagger_operation from avito.tariffs.client import TariffsClient from avito.tariffs.models import TariffInfo @@ -13,8 +14,18 @@ class Tariff(DomainObject): """Доменный объект тарифа.""" + __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", + ) def get_tariff_info(self) -> TariffInfo: """Получает информацию о тарифе аккаунта. diff --git a/avito/testing/__init__.py b/avito/testing/__init__.py index fa06a8e..b6178eb 100644 --- a/avito/testing/__init__.py +++ b/avito/testing/__init__.py @@ -8,12 +8,20 @@ json_response, route_sequence, ) +from avito.testing.swagger_fake_transport import ( + SwaggerFakeTransport, + SwaggerRoute, + error_payload, +) __all__ = ( "FakeTransport", "FakeResponse", "JsonValue", "RecordedRequest", + "SwaggerFakeTransport", + "SwaggerRoute", + "error_payload", "json_response", "route_sequence", ) diff --git a/avito/testing/swagger_fake_transport.py b/avito/testing/swagger_fake_transport.py new file mode 100644 index 0000000..d3d570d --- /dev/null +++ b/avito/testing/swagger_fake_transport.py @@ -0,0 +1,737 @@ +"""Swagger-aware fake transport for SDK contract tests.""" + +from __future__ import annotations + +import inspect +import json +import re +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import TYPE_CHECKING, cast +from urllib.parse import parse_qs + +import httpx + +from avito.auth import AuthSettings +from avito.auth.models import ClientCredentialsRequest, RefreshTokenRequest +from avito.auth.provider import AlternateTokenClient, TokenClient +from avito.client import AvitoClient +from avito.core.swagger_discovery import DiscoveredSwaggerBinding +from avito.core.swagger_registry import SwaggerOperation, SwaggerRegistry, SwaggerResponse +from avito.testing.fake_transport import FakeTransport, JsonValue, RecordedRequest + +if TYPE_CHECKING: + from avito.orders.models import DeliveryAddress, DeliveryRestriction, WeeklySchedule + +SdkValue = object + +_PATH_PARAMETER_RE = re.compile(r"{([A-Za-z_][A-Za-z0-9_]*)}") +_SDK_CONSTANTS: Mapping[str, SdkValue] = { + "account_id": 7, + "action_id": 101, + "call_id": 102, + "campaign_id": 103, + "chat_id": "chat-1", + "delivery_provider_id": "provider-1", + "dictionary_id": 104, + "employee_id": 10, + "grant_type": "client_credentials", + "item_id": 105, + "item_ids": [105], + "limit": 2, + "message_id": "message-1", + "offset": 0, + "order_id": 106, + "parcel_id": 107, + "price": 1500, + "report_id": 108, + "review_id": 115, + "resume_id": 109, + "scoring_id": 110, + "tariff_id": 111, + "teaser_id": "teaser-1", + "task_id": 112, + "user_id": 7, + "url": "https://example.test/file.xml", + "vacancy_id": 113, + "vacancy_uuid": "vacancy-uuid-1", + "value": "value", + "vehicle_id": 114, + "voice_ids": ["voice-1"], +} +_BODY_VALUES: Mapping[str, SdkValue] = { + "action": "approve", + "action_type_id": 1, + "action_id": 101, + "action_ids": [101], + "applies": [], + "auto_renewal": True, + "bid_penny": 1000, + "billing_type": "package", + "blacklisted_user_id": 7, + "blocked_dates": [{"date": "2026-05-01"}], + "brand_id": 1, + "budget_penny": 1000, + "budget_type": "daily", + "call_id": 102, + "campaign_id": 103, + "code": "1234", + "codes": ["xl"], + "date_time_from": "2026-04-01T00:00:00+00:00", + "date_time_to": "2026-04-02T00:00:00+00:00", + "employee_id": 10, + "files": ["file-1"], + "ids": [101], + "image_id": "image-1", + "intervals": [{"date_from": "2026-05-01", "date_to": "2026-05-02"}], + "item_id": 105, + "item_ids": [105], + "limit": 2, + "message": "Тестовое сообщение", + "mileage": 10000, + "min_stay_days": 2, + "name": "Тариф", + "order_id": 106, + "package_code": "xl", + "periods": [{"date_from": "2026-05-01", "date_to": "2026-05-02"}], + "pickup_point_id": 1, + "plate_number": "А123АА77", + "postal_office_id": 1, + "preview_id": 1, + "price": 1500, + "reason": "test", + "reg_number": "А123АА77", + "specification_id": 1, + "task_id": 112, + "text": "Ответ", + "title": "Тест", + "transition": "confirm", + "url": "https://example.test/file.xml", + "vacancy_id": 113, + "vehicle_id": 114, + "vehicles": [{"vin": "XTA210990Y2766384"}], + "vin": "XTA210990Y2766384", +} + + +@dataclass(frozen=True, slots=True) +class SwaggerRoute: + """Registered fake route bound to one Swagger operation.""" + + operation: SwaggerOperation + payload: JsonValue + status_code: int + headers: Mapping[str, str] + + +class SwaggerFakeTransport(FakeTransport): + """Fake transport that validates requests against local Swagger operations.""" + + def __init__( + self, + *, + registry: SwaggerRegistry, + base_url: str = "https://api.avito.ru", + ) -> None: + super().__init__(base_url=base_url) + self.registry = registry + self._swagger_routes: dict[str, SwaggerRoute] = {} + + def add_operation( + self, + operation_key: str, + payload: JsonValue, + *, + status_code: int = 200, + headers: Mapping[str, str] | None = None, + ) -> SwaggerFakeTransport: + """Register response for one Swagger operation key.""" + + operation = self.operation(operation_key) + self._validate_declared_status(operation, status_code) + self._swagger_routes[operation.key] = SwaggerRoute( + operation=operation, + payload=payload, + status_code=status_code, + headers=dict(headers or {}), + ) + return self + + def add_success_operation( + self, + operation_key: str, + *, + payload: JsonValue | None = None, + headers: Mapping[str, str] | None = None, + ) -> SwaggerFakeTransport: + """Register a deterministic success response for one Swagger operation.""" + + operation = self.operation(operation_key) + response = _success_response(operation) + status_code = int(response.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 metadata by canonical key.""" + + for operation in self.registry.operations: + if operation.key == operation_key: + return operation + raise AssertionError(f"Swagger operation не найдена: {operation_key}") + + def invoke_binding( + self, + binding: DiscoveredSwaggerBinding, + *, + client: AvitoClient | None = None, + ) -> object: + """Build and invoke SDK call from discovered Swagger binding metadata.""" + + if binding.operation_key is None: + raise AssertionError(f"Binding ambiguous: {binding.sdk_method}") + if binding.domain == "auth": + target = self._build_auth_target(binding) + method = getattr(target, binding.method_name) + return method(**self._build_arguments(binding.method_args, method)) + sdk_client = client or self.as_client(user_id=cast(int, _SDK_CONSTANTS["user_id"])) + target = self._build_target(sdk_client, binding) + method = getattr(target, binding.method_name) + return method(**self._build_arguments(binding.method_args, method)) + + def _handle(self, request: httpx.Request) -> httpx.Response: + recorded = RecordedRequest( + method=request.method.upper(), + path=request.url.path, + params=dict(request.url.params), + headers=dict(request.headers), + json_body=self._decode_json(request), + content=request.content, + ) + self.requests.append(recorded) + + 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: AvitoClient, + binding: DiscoveredSwaggerBinding, + ) -> object: + if binding.factory is None: + raise AssertionError(f"Binding не содержит AvitoClient factory: {binding.sdk_method}") + factory = getattr(client, binding.factory) + return factory(**self._build_arguments(binding.factory_args, factory)) + + def _build_auth_target(self, binding: DiscoveredSwaggerBinding) -> object: + settings = AuthSettings( + client_id="fake-client-id", + client_secret="fake-client-secret", + refresh_token="fake-refresh-token", + scope="fake-scope", + token_url=binding.path, + alternate_token_url=binding.path, + autoteka_token_url="/token", + autoteka_client_id="fake-autoteka-client-id", + autoteka_client_secret="fake-autoteka-client-secret", + autoteka_scope="autoteka:read", + ) + client = httpx.Client( + transport=httpx.MockTransport(self._handle), + base_url=self.base_url, + ) + if binding.class_name == "AlternateTokenClient": + return AlternateTokenClient(settings=settings, client=client) + if binding.class_name == "TokenClient": + return TokenClient(settings=settings, client=client) + raise AssertionError(f"Неподдерживаемый auth binding: {binding.sdk_method}") + + def _build_arguments( + self, + mapping: Mapping[str, str], + callable_object: Callable[..., object], + ) -> dict[str, object]: + signature = inspect.signature(callable_object) + arguments = {} + for argument_name, expression in mapping.items(): + parameter = signature.parameters.get(argument_name) + arguments[argument_name] = self._value_for_argument( + argument_name, + expression, + parameter, + ) + for name, parameter in signature.parameters.items(): + if name == "self" or name in arguments: + continue + if ( + parameter.default is inspect.Parameter.empty + or self._should_supply_optional_argument(name, parameter) + ): + arguments[name] = self._value_for_argument(name, f"constant.{name}", parameter) + return arguments + + def _value_for_argument( + self, + argument_name: str, + expression: str, + parameter: inspect.Parameter | None, + ) -> object: + annotation = _annotation_name(parameter) + if "ClientCredentialsRequest" in annotation: + return ClientCredentialsRequest( + client_id="fake-client-id", + client_secret="fake-client-secret", + scope="fake-scope", + ) + if "RefreshTokenRequest" in annotation: + return RefreshTokenRequest( + client_id="fake-client-id", + client_secret="fake-client-secret", + refresh_token="fake-refresh-token", + scope="fake-scope", + ) + if argument_name == "query": + return self._query_value(annotation) + if argument_name == "files" or "UploadImageFile" in annotation: + from avito.messenger.models import UploadImageFile + + return [ + UploadImageFile( + field_name="image", + filename="image.jpg", + content=b"image-bytes", + content_type="image/jpeg", + ) + ] + if expression == "body": + return self._body_value(argument_name, annotation) + return self._value_for_expression(expression, argument_name=argument_name, annotation=annotation) + + def _value_for_expression( + self, + expression: str, + *, + argument_name: str, + annotation: str, + ) -> object: + if expression == "body": + return self._body_value(argument_name, annotation) + prefix, separator, field_name = expression.partition(".") + if not separator: + raise AssertionError(f"Некорректное binding expression: {expression}") + if prefix in {"path", "query", "header", "constant"}: + return self._value_for_name(field_name) + if prefix == "body": + return self._body_field_value(argument_name, field_name, annotation) + raise AssertionError(f"Неподдерживаемое binding expression: {expression}") + + def _query_value(self, annotation: str) -> object: + if "MonitoringEventsQuery" in annotation: + from avito.autoteka.models import MonitoringEventsQuery + + return MonitoringEventsQuery(limit=2) + if "ApplicationIdsQuery" in annotation: + from avito.jobs.models import ApplicationIdsQuery + + return ApplicationIdsQuery(updated_at_from="2026-04-01T00:00:00+00:00") + if "ResumeSearchQuery" in annotation: + from avito.jobs.models import ResumeSearchQuery + + return ResumeSearchQuery(query="python") + if "VacanciesQuery" in annotation: + from avito.jobs.models import VacanciesQuery + + return VacanciesQuery(query="python") + if "ReviewsQuery" in annotation: + from avito.ratings.models import ReviewsQuery + + return ReviewsQuery(offset=0, limit=10) + return self._value_for_name("query") + + def _body_value(self, argument_name: str, annotation: str) -> object: + if "SandboxArea" in annotation: + from avito.orders.models import SandboxArea + + return [SandboxArea(city="Москва")] + if "SortingCenterUpload" in annotation: + return [self._sorting_center_upload()] + if "TaggedSortingCenter" in annotation: + from avito.orders.models import TaggedSortingCenter + + return [TaggedSortingCenter(delivery_provider_id="provider-1", direction_tag="tag-1")] + if "TerminalUpload" in annotation: + return [self._terminal_upload()] + if "DeliveryTermsZone" in annotation: + from avito.orders.models import DeliveryTermsZone + + return [DeliveryTermsZone(delivery_provider_zone_id="zone-1", min_term=1, max_term=2)] + if "StockUpdateEntry" in annotation: + from avito.orders.models import StockUpdateEntry + + return [StockUpdateEntry(item_id=105, quantity=5)] + return self._body_field_value(argument_name, argument_name, annotation) + + def _body_field_value(self, argument_name: str, field_name: str, annotation: str) -> object: + if argument_name == "applies" or "ApplicationViewedItem" in annotation: + from avito.jobs.models import ApplicationViewedItem + + return [ApplicationViewedItem(id="apply-1", is_viewed=True)] + if "BbipItemInput" in annotation: + return [{"item_id": 105, "duration": 7, "price": 1500, "old_price": 2000}] + if "TrxItemInput" in annotation: + return [ + { + "item_id": 105, + "commission": 10, + "date_from": datetime(2026, 5, 1, tzinfo=UTC), + } + ] + if "BidItemInput" in annotation: + return [{"item_id": 105, "price_penny": 1000}] + if "StockUpdateEntry" in annotation: + from avito.orders.models import StockUpdateEntry + + return [StockUpdateEntry(item_id=105, quantity=5)] + if field_name == "directions": + from avito.orders.models import DeliveryDirection, DeliveryDirectionZone + + return [ + DeliveryDirection( + provider_direction_id="direction-1", + tag_from="from", + tag_to="to", + zones=[DeliveryDirectionZone(tariff_zone_id="tariff-zone-1")], + ) + ] + if field_name == "tariff_zones": + from avito.orders.models import ( + DeliveryTariffItem, + DeliveryTariffValue, + DeliveryTariffZone, + ) + + return [ + DeliveryTariffZone( + name="Зона", + delivery_provider_zone_id="zone-1", + items=[ + DeliveryTariffItem( + calculation_mechanic="fixed", + chargeable_parameter="weight", + service_name="delivery", + values=[DeliveryTariffValue(cost=100)], + ) + ], + ) + ] + if field_name == "terms_zones": + from avito.orders.models import DeliveryTermsZone + + return [DeliveryTermsZone(delivery_provider_zone_id="zone-1", min_term=1, max_term=2)] + if field_name == "periods" or "RealtyPricePeriod" in annotation: + from avito.realty.models import RealtyPricePeriod + + return [RealtyPricePeriod(date_from="2026-05-01", price=1500)] + if "SandboxCancelAnnouncementOptions" in annotation: + from avito.orders.models import SandboxCancelAnnouncementOptions + + return SandboxCancelAnnouncementOptions( + url_to_cancel_announcement="https://example.test/cancel" + ) + if field_name == "sender" or "SandboxAnnouncementParticipant" in annotation: + return self._sandbox_participant("sender") + if field_name == "receiver": + return self._sandbox_participant("receiver") + if field_name == "packages" or "SandboxAnnouncementPackage" in annotation: + from avito.orders.models import SandboxAnnouncementPackage + + return [SandboxAnnouncementPackage(package_id="package-1", parcel_ids=["parcel-1"])] + if "SandboxCreateAnnouncementOptions" in annotation: + from avito.orders.models import SandboxCreateAnnouncementOptions + + return SandboxCreateAnnouncementOptions( + url_to_send_announcement="https://example.test/send" + ) + if "OrderDeliveryProperties" in annotation: + from avito.orders.models import OrderDeliveryProperties + + return OrderDeliveryProperties(dimensions=[10, 10, 10], weight=100) + if "RealAddress" in annotation: + from avito.orders.models import RealAddress + + return RealAddress(address_type="terminal", terminal_number="terminal-1") + if "CustomAreaScheduleEntry" in annotation: + from avito.orders.models import CustomAreaScheduleEntry, DeliveryDateInterval + + return [ + CustomAreaScheduleEntry( + provider_area_numbers=["area-1"], + services=["delivery"], + custom_schedule=[ + DeliveryDateInterval(date="2026-05-01", intervals=["09:00-18:00"]) + ], + ) + ] + return self._value_for_name(field_name) + + def _should_supply_optional_argument( + self, + name: str, + parameter: inspect.Parameter, + ) -> bool: + if parameter.default is not None: + return False + return name in _SDK_CONSTANTS or name in {"item_ids", "query"} + + def _value_for_name(self, name: str) -> object: + if name == "intervals": + from avito.realty.models import RealtyInterval + + return [RealtyInterval(date="2026-05-01", available=True)] + if name == "blocked_dates": + return ["2026-05-01"] + if name == "date_start": + return "2026-05-01" + if name == "date_end": + return "2026-05-02" + if name in _BODY_VALUES: + return _BODY_VALUES[name] + if name in _SDK_CONSTANTS: + return _SDK_CONSTANTS[name] + return f"{name}-value" + + def _sorting_center_upload(self) -> object: + from avito.orders.models import SortingCenterUpload + + return SortingCenterUpload( + delivery_provider_id="provider-1", + name="СЦ", + address=self._delivery_address(), + phones=["+70000000000"], + itinerary="Вход", + photos=["photo-1"], + schedule=self._weekly_schedule(), + restriction=self._delivery_restriction(), + direction_tag="tag-1", + ) + + def _terminal_upload(self) -> object: + from avito.orders.models import TerminalUpload + + return TerminalUpload( + delivery_provider_id="provider-1", + name="ПВЗ", + address=self._delivery_address(), + phones=["+70000000000"], + itinerary="Вход", + photos=["photo-1"], + direction_tag="tag-1", + services=["pickup"], + schedule=self._weekly_schedule(), + restriction=self._delivery_restriction(), + ) + + def _delivery_address(self) -> DeliveryAddress: + from avito.orders.models import DeliveryAddress + + return DeliveryAddress( + country="RU", + region="Москва", + locality="Москва", + fias="fias-1", + zip_code="101000", + lat=55.75, + lng=37.62, + ) + + def _weekly_schedule(self) -> WeeklySchedule: + from avito.orders.models import WeeklySchedule + + hours = ["09:00-18:00"] + return WeeklySchedule( + mon=hours, + tue=hours, + wed=hours, + thu=hours, + fri=hours, + sat=hours, + sun=hours, + ) + + def _delivery_restriction(self) -> DeliveryRestriction: + from avito.orders.models import DeliveryRestriction + + return DeliveryRestriction( + max_weight=1000, + max_dimensions=[10, 10, 10], + max_declared_cost=10000, + ) + + def _sandbox_participant(self, participant_type: str) -> object: + from avito.orders.models import ( + SandboxAnnouncementDelivery, + SandboxAnnouncementParticipant, + SandboxDeliveryPoint, + ) + + return SandboxAnnouncementParticipant( + type=participant_type, + phones=["+70000000000"], + email=f"{participant_type}@example.test", + name=participant_type, + delivery=SandboxAnnouncementDelivery( + type="terminal", + terminal=SandboxDeliveryPoint(provider="pochta", point_id="point-1"), + ), + ) + + def _match_route(self, request: RecordedRequest) -> SwaggerRoute: + for route in self._swagger_routes.values(): + if route.operation.method != request.method: + continue + if self._path_matches(route.operation.path, request.path): + return route + available = ", ".join( + f"{route.operation.method} {route.operation.path}" + for route in self._swagger_routes.values() + ) + raise AssertionError( + f"Маршрут не соответствует Swagger operation: {request.method} " + f"{request.path}. Доступные: {available}" + ) + + def _validate_request(self, operation: SwaggerOperation, request: RecordedRequest) -> None: + path_values = self._extract_path_values(operation.path, request.path) + form_values = self._form_values(request) + for parameter in operation.parameters: + if parameter.location == "path" and parameter.required: + if parameter.name not in path_values: + raise AssertionError(f"Не найден path parameter `{parameter.name}`.") + if parameter.location == "query" and parameter.required: + if parameter.name not in request.params and parameter.name not in form_values: + raise AssertionError(f"Не найден query parameter `{parameter.name}`.") + if parameter.location == "header" and parameter.required: + if parameter.name.lower() == "authorization": + continue + headers = {name.lower() for name in request.headers} + if parameter.name.lower() not in headers: + raise AssertionError(f"Не найден header parameter `{parameter.name}`.") + if operation.request_body is None: + return + if operation.request_body.required and request.content == b"": + raise AssertionError(f"{operation.key}: requestBody обязателен.") + content_type = request.headers.get("content-type", "") + if request.content and operation.request_body.content_types: + if not any(expected in content_type for expected in operation.request_body.content_types): + raise AssertionError( + f"{operation.key}: content-type `{content_type}` не описан в Swagger." + ) + if "application/json" in content_type and request.content: + try: + json.loads(request.content.decode()) + except json.JSONDecodeError as exc: + raise AssertionError(f"{operation.key}: requestBody не является JSON.") from exc + + def _form_values(self, request: RecordedRequest) -> Mapping[str, str]: + content_type = request.headers.get("content-type", "") + if "application/x-www-form-urlencoded" not in content_type or not request.content: + return {} + parsed = parse_qs(request.content.decode()) + return {name: values[-1] for name, values in parsed.items() if values} + + def _validate_declared_status(self, operation: SwaggerOperation, status_code: int) -> None: + declared = { + int(response.status_code) + for response in operation.responses + if response.status_code.isdigit() + } + if status_code not in declared: + raise AssertionError( + f"{operation.key}: status {status_code} не описан в Swagger responses." + ) + + def _path_matches(self, template: str, path: str) -> bool: + return self._path_pattern(template).fullmatch(self._normalize_swagger_path(path)) is not None + + def _extract_path_values(self, template: str, path: str) -> Mapping[str, str]: + match = self._path_pattern(template).fullmatch(self._normalize_swagger_path(path)) + return match.groupdict() if match is not None else {} + + def _path_pattern(self, template: str) -> re.Pattern[str]: + pattern = "^" + position = 0 + for match in _PATH_PARAMETER_RE.finditer(template): + pattern += re.escape(template[position : match.start()]) + pattern += f"(?P<{match.group(1)}>[^/]+)" + position = match.end() + pattern += re.escape(template[position:]) + pattern += "$" + return re.compile(pattern) + + def _normalize_swagger_path(self, path: str) -> str: + if path != "/": + return path.rstrip("/") + return path + + +def error_payload(status_code: int) -> JsonValue: + """Build deterministic JSON error payload for contract tests.""" + + return { + "message": f"Ошибка {status_code}", + "code": f"status_{status_code}", + "details": {"status": status_code}, + } + + +def success_payload(operation: SwaggerOperation) -> JsonValue: + """Build deterministic success payload for one operation.""" + + if operation.spec in {"Авторизация.json", "Автотека.json"} and operation.path.startswith("/token"): + return { + "access_token": "access-token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "refresh-token", + "scope": "fake-scope", + } + if operation.key == "CallTracking[КТ].json POST /calltracking/v1/getCallById": + return {"call": {"id": "call-1"}, "error": {"code": 0, "message": "ok"}} + if operation.key == "Продвижение.json POST /promotion/v1/items/services/orders/status": + return {"orderId": "order-1", "status": "active", "items": [], "errors": []} + if operation.key == "Настройкаценыцелевогодействия.json GET /cpxpromo/1/getBids/{itemId}": + return {"actionTypeID": 1, "selectedType": "manual", "manual": {}, "auto": {}} + if operation.key == "Настройкаценыцелевогодействия.json POST /cpxpromo/1/getPromotionsByItemIds": + return {"items": []} + return {} + + +def _success_response(operation: SwaggerOperation) -> SwaggerResponse: + for response in operation.success_responses: + if response.status_code.isdigit(): + return response + raise AssertionError(f"{operation.key}: Swagger operation не содержит success response.") + + +def _annotation_name(parameter: inspect.Parameter | None) -> str: + if parameter is None: + return "" + annotation = parameter.annotation + if annotation is inspect.Parameter.empty: + return "" + return str(annotation) + + +__all__ = ("SwaggerFakeTransport", "SwaggerRoute", "error_payload", "success_payload") diff --git "a/docs/avito/api/CPA-\320\260\321\203\320\272\321\206\320\270\320\276\320\275.json" "b/docs/avito/api/CPA-\320\260\321\203\320\272\321\206\320\270\320\276\320\275.json" index 9dd03c2..7895648 100644 --- "a/docs/avito/api/CPA-\320\260\321\203\320\272\321\206\320\270\320\276\320\275.json" +++ "b/docs/avito/api/CPA-\320\260\321\203\320\272\321\206\320\270\320\276\320\275.json" @@ -416,4 +416,4 @@ "x-displayName": "CPA-аукцион" } ] -} \ No newline at end of file +} diff --git "a/docs/avito/api/CPA\320\220\320\262\320\270\321\202\320\276.json" "b/docs/avito/api/CPA\320\220\320\262\320\270\321\202\320\276.json" index 1a2a2b8..50a975e 100644 --- "a/docs/avito/api/CPA\320\220\320\262\320\270\321\202\320\276.json" +++ "b/docs/avito/api/CPA\320\220\320\262\320\270\321\202\320\276.json" @@ -1947,4 +1947,4 @@ "x-displayName": "API CPA Авито" } ] -} \ No newline at end of file +} diff --git "a/docs/avito/api/CallTracking[\320\232\320\242].json" "b/docs/avito/api/CallTracking[\320\232\320\242].json" index ca10b8b..4e089bf 100644 --- "a/docs/avito/api/CallTracking[\320\232\320\242].json" +++ "b/docs/avito/api/CallTracking[\320\232\320\242].json" @@ -422,4 +422,4 @@ "x-displayName": "CallTracking" } ] -} \ No newline at end of file +} diff --git a/docs/avito/api/TrxPromo.json b/docs/avito/api/TrxPromo.json index 12caf9e..94ed973 100644 --- a/docs/avito/api/TrxPromo.json +++ b/docs/avito/api/TrxPromo.json @@ -689,4 +689,4 @@ "x-displayName": "TrxPromo" } ] -} \ No newline at end of file +} diff --git "a/docs/avito/api/\320\220\320\262\320\270\321\202\320\276\320\240\320\260\320\261\320\276\321\202\320\260.json" "b/docs/avito/api/\320\220\320\262\320\270\321\202\320\276\320\240\320\260\320\261\320\276\321\202\320\260.json" index 66b308e..ef8620d 100644 --- "a/docs/avito/api/\320\220\320\262\320\270\321\202\320\276\320\240\320\260\320\261\320\276\321\202\320\260.json" +++ "b/docs/avito/api/\320\220\320\262\320\270\321\202\320\276\320\240\320\260\320\261\320\276\321\202\320\260.json" @@ -3741,7 +3741,9 @@ "$ref": "#/components/schemas/Bonuses" }, "business_area": { - "$ref": "#/components/schemas/BusinessArea" + "description": "Идентификатор сферы деятельности \n
\nПолучить актуальный список доступных значений можно из справочника `business_area` через метод [getDictByID](/api-catalog/job/documentation#operation/getDictByID).\n
\n", + "nullable": true, + "type": "integer" }, "citizenship": { "$ref": "#/components/schemas/CitizenshipCriteria" @@ -3799,6 +3801,7 @@ }, "description": { "description": "Описание вакансии (строка длиной от 1 до 5000 символов)\n\nМожно использовать HTML-теги в тексте.\n\nПоддерживаемые тэги - `p`, `ul`, `ol`, `li`, `br`, `strong`, `em`\n", + "nullable": true, "type": "string" }, "driving_experience": { @@ -3825,6 +3828,7 @@ }, "employment": { "description": "Занятость
\nВозможные значения:\n - temporary - Временная\n - full - Полная\n - internship - Стажировка\n - partial - Частичная\n\nЕсли ничего не выбрать то будет автоматически проставляться в зависимости от графика работы: \nПри flexible и partTime, тип занятости - partial.\nßДля всех остальных full.\n", + "nullable": true, "type": "string" }, "experience": { @@ -3836,6 +3840,7 @@ "moreThan5", "moreThan10" ], + "nullable": true, "type": "string" }, "facility_type": { @@ -3872,6 +3877,7 @@ }, "location": { "description": "Геолокация вакансии (как минимум одно из значений)", + "nullable": true, "properties": { "address": { "$ref": "#/components/schemas/LocationAddress" @@ -3952,6 +3958,7 @@ "flexible", "shift" ], + "nullable": true, "type": "string" }, "shifts": { @@ -3959,6 +3966,7 @@ }, "title": { "description": "Название вакансии (строка длиной от 1 до 50 символов)", + "nullable": true, "type": "string" }, "tools_availability": { @@ -3995,14 +4003,7 @@ } }, "required": [ - "title", - "description", - "billing_type", - "business_area", - "employment", - "schedule", - "experience", - "location" + "billing_type" ], "type": "object" }, @@ -7868,4 +7869,4 @@ "x-displayName": "Работа" } ] -} \ No newline at end of file +} diff --git "a/docs/avito/api/\320\220\320\262\321\202\320\276\320\267\320\260\320\263\321\200\321\203\320\267\320\272\320\260.json" "b/docs/avito/api/\320\220\320\262\321\202\320\276\320\267\320\260\320\263\321\200\321\203\320\267\320\272\320\260.json" index 54c3480..185c873 100644 --- "a/docs/avito/api/\320\220\320\262\321\202\320\276\320\267\320\260\320\263\321\200\321\203\320\267\320\272\320\260.json" +++ "b/docs/avito/api/\320\220\320\262\321\202\320\276\320\267\320\260\320\263\321\200\321\203\320\267\320\272\320\260.json" @@ -3,10 +3,10 @@ "headers": { "LastModifiedSinceHeader": { "description": "Дата и время последней полученной версии в формате RFC1123 в UTC", + "example": "Mon, 01 Jan 0001 00:00:00 UTC", "schema": { "type": "string" - }, - "example": "Mon, 01 Jan 0001 00:00:00 UTC" + } }, "StoreFrontCacheHeader": { "description": "Заголовок говорит нужно ли кэшировать ответ на стороне фронта", @@ -3344,4 +3344,4 @@ "x-displayName": "Сервис Автозагрузка" } ] -} \ No newline at end of file +} diff --git "a/docs/avito/api/\320\220\320\262\321\202\320\276\321\200\320\270\320\267\320\260\321\206\320\270\321\217.json" "b/docs/avito/api/\320\220\320\262\321\202\320\276\321\200\320\270\320\267\320\260\321\206\320\270\321\217.json" index 0133429..0ce549c 100644 --- "a/docs/avito/api/\320\220\320\262\321\202\320\276\321\200\320\270\320\267\320\260\321\206\320\270\321\217.json" +++ "b/docs/avito/api/\320\220\320\262\321\202\320\276\321\200\320\270\320\267\320\260\321\206\320\270\321\217.json" @@ -453,4 +453,4 @@ "x-displayName": "Авторизация для приложений" } ] -} \ No newline at end of file +} diff --git "a/docs/avito/api/\320\220\320\262\321\202\320\276\321\201\321\202\321\200\320\260\321\202\320\265\320\263\320\270\321\217.json" "b/docs/avito/api/\320\220\320\262\321\202\320\276\321\201\321\202\321\200\320\260\321\202\320\265\320\263\320\270\321\217.json" index 472bd1e..7a143b9 100644 --- "a/docs/avito/api/\320\220\320\262\321\202\320\276\321\201\321\202\321\200\320\260\321\202\320\265\320\263\320\270\321\217.json" +++ "b/docs/avito/api/\320\220\320\262\321\202\320\276\321\201\321\202\321\200\320\260\321\202\320\265\320\263\320\270\321\217.json" @@ -1951,4 +1951,4 @@ "x-displayName": "Autostrategy" } ] -} \ No newline at end of file +} diff --git "a/docs/avito/api/\320\220\320\262\321\202\320\276\321\202\320\265\320\272\320\260.json" "b/docs/avito/api/\320\220\320\262\321\202\320\276\321\202\320\265\320\272\320\260.json" index 226c386..1971d60 100644 --- "a/docs/avito/api/\320\220\320\262\321\202\320\276\321\202\320\265\320\272\320\260.json" +++ "b/docs/avito/api/\320\220\320\262\321\202\320\276\321\202\320\265\320\272\320\260.json" @@ -9993,4 +9993,4 @@ } ] } -} \ No newline at end of file +} diff --git "a/docs/avito/api/\320\220\320\275\320\260\320\273\320\270\321\202\320\270\320\272\320\260\320\277\320\276\320\275\320\265\320\264\320\262\320\270\320\266\320\270\320\274\320\276\321\201\321\202\320\270.json" "b/docs/avito/api/\320\220\320\275\320\260\320\273\320\270\321\202\320\270\320\272\320\260\320\277\320\276\320\275\320\265\320\264\320\262\320\270\320\266\320\270\320\274\320\276\321\201\321\202\320\270.json" index 609b2ca..9c88731 100644 --- "a/docs/avito/api/\320\220\320\275\320\260\320\273\320\270\321\202\320\270\320\272\320\260\320\277\320\276\320\275\320\265\320\264\320\262\320\270\320\266\320\270\320\274\320\276\321\201\321\202\320\270.json" +++ "b/docs/avito/api/\320\220\320\275\320\260\320\273\320\270\321\202\320\270\320\272\320\260\320\277\320\276\320\275\320\265\320\264\320\262\320\270\320\266\320\270\320\274\320\276\321\201\321\202\320\270.json" @@ -378,4 +378,4 @@ "x-displayName": "API аналитики по недвижимости" } ] -} \ No newline at end of file +} diff --git "a/docs/avito/api/\320\224\320\276\321\201\321\202\320\260\320\262\320\272\320\260.json" "b/docs/avito/api/\320\224\320\276\321\201\321\202\320\260\320\262\320\272\320\260.json" index 6b35409..28fd827 100644 --- "a/docs/avito/api/\320\224\320\276\321\201\321\202\320\260\320\262\320\272\320\260.json" +++ "b/docs/avito/api/\320\224\320\276\321\201\321\202\320\260\320\262\320\272\320\260.json" @@ -7210,4 +7210,4 @@ "x-subdivName": "Песочница" } ] -} \ No newline at end of file +} diff --git "a/docs/avito/api/\320\230\320\265\321\200\320\260\321\200\321\205\320\270\321\217\320\220\320\272\320\272\320\260\321\203\320\275\321\202\320\276\320\262.json" "b/docs/avito/api/\320\230\320\265\321\200\320\260\321\200\321\205\320\270\321\217\320\220\320\272\320\272\320\260\321\203\320\275\321\202\320\276\320\262.json" index d463ad3..ef541e7 100644 --- "a/docs/avito/api/\320\230\320\265\321\200\320\260\321\200\321\205\320\270\321\217\320\220\320\272\320\272\320\260\321\203\320\275\321\202\320\276\320\262.json" +++ "b/docs/avito/api/\320\230\320\265\321\200\320\260\321\200\321\205\320\270\321\217\320\220\320\272\320\272\320\260\321\203\320\275\321\202\320\276\320\262.json" @@ -934,4 +934,4 @@ "x-displayName": "Иерархия Аккаунтов" } ] -} \ No newline at end of file +} diff --git "a/docs/avito/api/\320\230\320\275\321\204\320\276\321\200\320\274\320\260\321\206\320\270\321\217\320\276\320\277\320\276\320\273\321\214\320\267\320\276\320\262\320\260\321\202\320\265\320\273\320\265.json" "b/docs/avito/api/\320\230\320\275\321\204\320\276\321\200\320\274\320\260\321\206\320\270\321\217\320\276\320\277\320\276\320\273\321\214\320\267\320\276\320\262\320\260\321\202\320\265\320\273\320\265.json" index 56f5edb..ff464e6 100644 --- "a/docs/avito/api/\320\230\320\275\321\204\320\276\321\200\320\274\320\260\321\206\320\270\321\217\320\276\320\277\320\276\320\273\321\214\320\267\320\276\320\262\320\260\321\202\320\265\320\273\320\265.json" +++ "b/docs/avito/api/\320\230\320\275\321\204\320\276\321\200\320\274\320\260\321\206\320\270\321\217\320\276\320\277\320\276\320\273\321\214\320\267\320\276\320\262\320\260\321\202\320\265\320\273\320\265.json" @@ -760,4 +760,4 @@ "x-displayName": "Пользователь" } ] -} \ No newline at end of file +} diff --git "a/docs/avito/api/\320\232\321\200\320\260\321\202\320\272\320\276\321\201\321\200\320\276\321\207\320\275\320\260\321\217\320\260\321\200\320\265\320\275\320\264\320\260.json" "b/docs/avito/api/\320\232\321\200\320\260\321\202\320\272\320\276\321\201\321\200\320\276\321\207\320\275\320\260\321\217\320\260\321\200\320\265\320\275\320\264\320\260.json" index a9e8d8b..5157dfd 100644 --- "a/docs/avito/api/\320\232\321\200\320\260\321\202\320\272\320\276\321\201\321\200\320\276\321\207\320\275\320\260\321\217\320\260\321\200\320\265\320\275\320\264\320\260.json" +++ "b/docs/avito/api/\320\232\321\200\320\260\321\202\320\272\320\276\321\201\321\200\320\276\321\207\320\275\320\260\321\217\320\260\321\200\320\265\320\275\320\264\320\260.json" @@ -1237,4 +1237,4 @@ "x-displayName": "Краткосрочная аренда" } ] -} \ No newline at end of file +} diff --git "a/docs/avito/api/\320\234\320\265\321\201\321\201\320\265\320\275\320\264\320\266\320\265\321\200.json" "b/docs/avito/api/\320\234\320\265\321\201\321\201\320\265\320\275\320\264\320\266\320\265\321\200.json" index 4429c93..259196a 100644 --- "a/docs/avito/api/\320\234\320\265\321\201\321\201\320\265\320\275\320\264\320\266\320\265\321\200.json" +++ "b/docs/avito/api/\320\234\320\265\321\201\321\201\320\265\320\275\320\264\320\266\320\265\321\200.json" @@ -1953,4 +1953,4 @@ "x-displayName": "Messenger API" } ] -} \ No newline at end of file +} diff --git "a/docs/avito/api/\320\235\320\260\321\201\321\202\321\200\320\276\320\271\320\272\320\260\321\206\320\265\320\275\321\213\321\206\320\265\320\273\320\265\320\262\320\276\320\263\320\276\320\264\320\265\320\271\321\201\321\202\320\262\320\270\321\217.json" "b/docs/avito/api/\320\235\320\260\321\201\321\202\321\200\320\276\320\271\320\272\320\260\321\206\320\265\320\275\321\213\321\206\320\265\320\273\320\265\320\262\320\276\320\263\320\276\320\264\320\265\320\271\321\201\321\202\320\262\320\270\321\217.json" index 96ba4fd..2f00e11 100644 --- "a/docs/avito/api/\320\235\320\260\321\201\321\202\321\200\320\276\320\271\320\272\320\260\321\206\320\265\320\275\321\213\321\206\320\265\320\273\320\265\320\262\320\276\320\263\320\276\320\264\320\265\320\271\321\201\321\202\320\262\320\270\321\217.json" +++ "b/docs/avito/api/\320\235\320\260\321\201\321\202\321\200\320\276\320\271\320\272\320\260\321\206\320\265\320\275\321\213\321\206\320\265\320\273\320\265\320\262\320\276\320\263\320\276\320\264\320\265\320\271\321\201\321\202\320\262\320\270\321\217.json" @@ -912,4 +912,4 @@ "x-displayName": "Настройка цены целевого действия" } ] -} \ No newline at end of file +} diff --git "a/docs/avito/api/\320\236\320\261\321\212\321\217\320\262\320\273\320\265\320\275\320\270\321\217.json" "b/docs/avito/api/\320\236\320\261\321\212\321\217\320\262\320\273\320\265\320\275\320\270\321\217.json" index 8b83ea5..b7e5c1c 100644 --- "a/docs/avito/api/\320\236\320\261\321\212\321\217\320\262\320\273\320\265\320\275\320\270\321\217.json" +++ "b/docs/avito/api/\320\236\320\261\321\212\321\217\320\262\320\273\320\265\320\275\320\270\321\217.json" @@ -2500,4 +2500,4 @@ "x-displayName": "Объявления" } ] -} \ No newline at end of file +} diff --git "a/docs/avito/api/\320\237\321\200\320\276\320\264\320\262\320\270\320\266\320\265\320\275\320\270\320\265.json" "b/docs/avito/api/\320\237\321\200\320\276\320\264\320\262\320\270\320\266\320\265\320\275\320\270\320\265.json" index d008ec3..f8133a3 100644 --- "a/docs/avito/api/\320\237\321\200\320\276\320\264\320\262\320\270\320\266\320\265\320\275\320\270\320\265.json" +++ "b/docs/avito/api/\320\237\321\200\320\276\320\264\320\262\320\270\320\266\320\265\320\275\320\270\320\265.json" @@ -1504,4 +1504,4 @@ "x-displayName": "Услуга \"Продвижение с прогнозом\"" } ] -} \ No newline at end of file +} diff --git "a/docs/avito/api/\320\240\320\260\321\201\321\201\321\213\320\273\320\272\320\260\321\201\320\272\320\270\320\264\320\276\320\272\320\270\321\201\320\277\320\265\321\206\320\277\321\200\320\265\320\264\320\273\320\276\320\266\320\265\320\275\320\270\320\271\320\262\320\274\320\265\321\201\321\201\320\265\320\275\320\264\320\266\320\265\321\200\320\265.json" "b/docs/avito/api/\320\240\320\260\321\201\321\201\321\213\320\273\320\272\320\260\321\201\320\272\320\270\320\264\320\276\320\272\320\270\321\201\320\277\320\265\321\206\320\277\321\200\320\265\320\264\320\273\320\276\320\266\320\265\320\275\320\270\320\271\320\262\320\274\320\265\321\201\321\201\320\265\320\275\320\264\320\266\320\265\321\200\320\265.json" index d251c77..51fd708 100644 --- "a/docs/avito/api/\320\240\320\260\321\201\321\201\321\213\320\273\320\272\320\260\321\201\320\272\320\270\320\264\320\276\320\272\320\270\321\201\320\277\320\265\321\206\320\277\321\200\320\265\320\264\320\273\320\276\320\266\320\265\320\275\320\270\320\271\320\262\320\274\320\265\321\201\321\201\320\265\320\275\320\264\320\266\320\265\321\200\320\265.json" +++ "b/docs/avito/api/\320\240\320\260\321\201\321\201\321\213\320\273\320\272\320\260\321\201\320\272\320\270\320\264\320\276\320\272\320\270\321\201\320\277\320\265\321\206\320\277\321\200\320\265\320\264\320\273\320\276\320\266\320\265\320\275\320\270\320\271\320\262\320\274\320\265\321\201\321\201\320\265\320\275\320\264\320\266\320\265\321\200\320\265.json" @@ -1136,4 +1136,4 @@ "x-displayName": "Рассылка скидок и спецпредложений" } ] -} \ No newline at end of file +} diff --git "a/docs/avito/api/\320\240\320\265\320\271\321\202\320\270\320\275\320\263\320\270\320\270\320\276\321\202\320\267\321\213\320\262\321\213.json" "b/docs/avito/api/\320\240\320\265\320\271\321\202\320\270\320\275\320\263\320\270\320\270\320\276\321\202\320\267\321\213\320\262\321\213.json" index 703aa22..39a3460 100644 --- "a/docs/avito/api/\320\240\320\265\320\271\321\202\320\270\320\275\320\263\320\270\320\270\320\276\321\202\320\267\321\213\320\262\321\213.json" +++ "b/docs/avito/api/\320\240\320\265\320\271\321\202\320\270\320\275\320\263\320\270\320\270\320\276\321\202\320\267\321\213\320\262\321\213.json" @@ -963,4 +963,4 @@ "x-displayName": "Рейтинги и отзывы" } ] -} \ No newline at end of file +} diff --git "a/docs/avito/api/\320\242\320\260\321\200\320\270\321\204\321\213.json" "b/docs/avito/api/\320\242\320\260\321\200\320\270\321\204\321\213.json" index 53f7a00..53f3d7e 100644 --- "a/docs/avito/api/\320\242\320\260\321\200\320\270\321\204\321\213.json" +++ "b/docs/avito/api/\320\242\320\260\321\200\320\270\321\204\321\213.json" @@ -468,4 +468,4 @@ "x-displayName": "Tariff" } ] -} \ No newline at end of file +} diff --git "a/docs/avito/api/\320\243\320\277\321\200\320\260\320\262\320\273\320\265\320\275\320\270\320\265\320\267\320\260\320\272\320\260\320\267\320\260\320\274\320\270.json" "b/docs/avito/api/\320\243\320\277\321\200\320\260\320\262\320\273\320\265\320\275\320\270\320\265\320\267\320\260\320\272\320\260\320\267\320\260\320\274\320\270.json" index 55c7df5..b752363 100644 --- "a/docs/avito/api/\320\243\320\277\321\200\320\260\320\262\320\273\320\265\320\275\320\270\320\265\320\267\320\260\320\272\320\260\320\267\320\260\320\274\320\270.json" +++ "b/docs/avito/api/\320\243\320\277\321\200\320\260\320\262\320\273\320\265\320\275\320\270\320\265\320\267\320\260\320\272\320\260\320\267\320\260\320\274\320\270.json" @@ -1799,4 +1799,4 @@ "url": "https://api.avito.ru/" } ] -} \ No newline at end of file +} diff --git "a/docs/avito/api/\320\243\320\277\321\200\320\260\320\262\320\273\320\265\320\275\320\270\320\265\320\276\321\201\321\202\320\260\321\202\320\272\320\260\320\274\320\270.json" "b/docs/avito/api/\320\243\320\277\321\200\320\260\320\262\320\273\320\265\320\275\320\270\320\265\320\276\321\201\321\202\320\260\321\202\320\272\320\260\320\274\320\270.json" index 91c4214..e8c9b04 100644 --- "a/docs/avito/api/\320\243\320\277\321\200\320\260\320\262\320\273\320\265\320\275\320\270\320\265\320\276\321\201\321\202\320\260\321\202\320\272\320\260\320\274\320\270.json" +++ "b/docs/avito/api/\320\243\320\277\321\200\320\260\320\262\320\273\320\265\320\275\320\270\320\265\320\276\321\201\321\202\320\260\321\202\320\272\320\260\320\274\320\270.json" @@ -412,4 +412,4 @@ "url": "https://api.avito.ru/" } ] -} \ No newline at end of file +} diff --git a/docs/avito/inventory.md b/docs/avito/inventory.md deleted file mode 100644 index c4c98b3..0000000 --- a/docs/avito/inventory.md +++ /dev/null @@ -1,246 +0,0 @@ -# Инвентарь Swagger - -Инвентарь фиксирует покрытие этапа 1 для всех операций из `docs/avito/api/*.json` и служит источником истины для развития публичного API SDK, доменных тестов и документации репозитория. - -- Всего swagger-документов: 23. -- Всего операций: 204. -- Аномалии нормализуются прямо в inventory; сейчас это касается дублирующихся `/token` из `Авторизация.json` с невидимыми Unicode-символами. - -## Соответствие Документов И SDK - -| документ | раздел | пакет_sdk | доменный_объект_по_умолчанию | операций | -| --- | --- | --- | --- | ---: | -| `CPA-аукцион.json` | `promotion` | `promotion` | `CpaAuction` | 2 | -| `CPAАвито.json` | `cpa` | `cpa` | `CpaLead` | 11 | -| `CallTracking[КТ].json` | `cpa` | `cpa` | `CallTrackingCall` | 3 | -| `TrxPromo.json` | `promotion` | `promotion` | `TrxPromotion` | 3 | -| `АвитоРабота.json` | `jobs` | `jobs` | `Vacancy` | 25 | -| `Автозагрузка.json` | `ads` | `ads` | `AutoloadProfile` | 17 | -| `Авторизация.json` | `auth` | `auth` | `AvitoClient.auth()` | 3 | -| `Автостратегия.json` | `promotion` | `promotion` | `AutostrategyCampaign` | 7 | -| `Автотека.json` | `autoteka` | `autoteka` | `AutotekaVehicle` | 27 | -| `Аналитикапонедвижимости.json` | `realty` | `realty` | `RealtyAnalyticsReport` | 2 | -| `Доставка.json` | `orders` | `orders` | `DeliveryOrder` | 31 | -| `ИерархияАккаунтов.json` | `accounts` | `accounts` | `AccountHierarchy` | 5 | -| `Информацияопользователе.json` | `accounts` | `accounts` | `Account` | 3 | -| `Краткосрочнаяаренда.json` | `realty` | `realty` | `RealtyListing` | 5 | -| `Мессенджер.json` | `messenger` | `messenger` | `Chat` | 13 | -| `Настройкаценыцелевогодействия.json` | `promotion` | `promotion` | `TargetActionPricing` | 5 | -| `Объявления.json` | `ads` | `ads` | `Ad` | 11 | -| `Продвижение.json` | `promotion` | `promotion` | `PromotionOrder` | 7 | -| `Рассылкаскидокиспецпредложенийвмессенджере.json` | `messenger` | `messenger` | `SpecialOfferCampaign` | 5 | -| `Рейтингииотзывы.json` | `ratings` | `ratings` | `Review` | 4 | -| `Тарифы.json` | `tariffs` | `tariffs` | `Tariff` | 1 | -| `Управлениезаказами.json` | `orders` | `orders` | `Order` | 12 | -| `Управлениеостатками.json` | `orders` | `orders` | `Stock` | 2 | - -## Операции - - -| раздел | документ | метод | путь | описание | deprecated | deprecated_since | replacement | removal_version | пакет_sdk | доменный_объект | публичный_метод_sdk | тип_запроса | тип_ответа | тип_теста | примечания | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| promotion | CPA-аукцион.json | GET | /auction/1/bids | Получение информации о действующих и доступных ставках | нет | | | | promotion | CpaAuction | get_user_bids | NoRequest | GetUserBidsResponse | контракт+маппинг | | -| promotion | CPA-аукцион.json | POST | /auction/1/bids | Сохранение новых ставок | нет | | | | promotion | CpaAuction | create_item_bids | CreateItemBidsRequest | EmptyResponse | контракт+маппинг | | -| cpa | CPAАвито.json | GET | /cpa/v1/call/{call_id} | Запись звонка (deprecated) | да | 1.1.0 | call_tracking_call().download | 1.3.0 | cpa | CpaArchive | get_call | NoRequest | EmptyResponse | контракт+маппинг | | -| cpa | CPAАвито.json | GET | /cpa/v1/chatByActionId/{actionId} | Чат | нет | | | | cpa | CpaChat | get_chat_by_action_id | NoRequest | GetChatByActionIdResponse | контракт+маппинг | | -| cpa | CPAАвито.json | POST | /cpa/v1/chatsByTime | Чаты по времени (deprecated) | да | 1.1.0 | cpa_chat().list(version=2) | 1.3.0 | cpa | CpaChat | list | CreateChatsByTimeRequest | CreateChatsByTimeResponse | контракт+маппинг | deprecated при version=1 | -| cpa | CPAАвито.json | POST | /cpa/v1/createComplaint | Создание жалобы для звонков | нет | | | | cpa | CpaCall | create_create_complaint | CreateCreateComplaintRequest | CreateCreateComplaintResponse | контракт+маппинг | | -| cpa | CPAАвито.json | POST | /cpa/v1/createComplaintByActionId | Создание жалобы для звонков/чатов | нет | | | | cpa | CpaLead | create_complaint_by_action_id | CreateComplaintByActionIdRequest | CreateComplaintByActionIdResponse | контракт+маппинг | | -| cpa | CPAАвито.json | POST | /cpa/v1/phonesInfoFromChats | Информация по номерам телефонов из целевых чатов | нет | | | | cpa | CpaChat | get_phones_info_from_chats | GetPhonesInfoFromChatsRequest | GetPhonesInfoFromChatsResponse | контракт+маппинг | | -| cpa | CPAАвито.json | POST | /cpa/v2/balanceInfo | Баланс (deprecated) | да | 1.1.0 | cpa_lead().get_balance_info | 1.3.0 | cpa | CpaArchive | get_balance_info | LegacyCreateBalanceInfoV2Request | LegacyCreateBalanceInfoV2Response | контракт+маппинг | | -| cpa | CPAАвито.json | POST | /cpa/v2/callById | Звонок | да | 1.1.0 | call_tracking_call().get | 1.3.0 | cpa | CpaArchive | get_call_by_id | LegacyCreateCallByIdV2Request | LegacyCreateCallByIdV2Response | контракт+маппинг | | -| cpa | CPAАвито.json | POST | /cpa/v2/callsByTime | Звонки по времени | нет | | | | cpa | CpaCall | create_calls_by_time_v2 | CreateCallsByTimeV2Request | CreateCallsByTimeV2Response | контракт+маппинг | | -| cpa | CPAАвито.json | POST | /cpa/v2/chatsByTime | Чаты по времени | нет | | | | cpa | CpaChat | create_chats_by_time | CreateChatsByTimeRequest | CreateChatsByTimeResponse | контракт+маппинг | | -| cpa | CPAАвито.json | POST | /cpa/v3/balanceInfo | Баланс | нет | | | | cpa | CpaLead | create_balance_info_v3 | CreateBalanceInfoV3Request | CreateBalanceInfoV3Response | контракт+маппинг | | -| cpa | CallTracking[КТ].json | POST | /calltracking/v1/getCallById/ | Звонок по идентификатору | нет | | | | cpa | CallTrackingCall | create_call_by_id | CreateCallByIdRequest | EmptyResponse | контракт+маппинг | | -| cpa | CallTracking[КТ].json | POST | /calltracking/v1/getCalls/ | Звонки по времени | нет | | | | cpa | CallTrackingCall | create_calls | CreateCallsRequest | EmptyResponse | контракт+маппинг | | -| cpa | CallTracking[КТ].json | GET | /calltracking/v1/getRecordByCallId/ | Получение аудиозаписи звонка по идентификатору | нет | | | | cpa | CallTrackingCall | get_record_by_call_id | NoRequest | EmptyResponse | контракт+маппинг | | -| promotion | TrxPromo.json | POST | /trx-promo/1/apply | Запуск продвижения | нет | | | | promotion | TrxPromotion | create_trx_promo_open_api_apply | CreateTrxPromoOpenApiApplyRequest | CreateTrxPromoOpenApiApplyResponse | контракт+маппинг | | -| promotion | TrxPromo.json | POST | /trx-promo/1/cancel | Остановка продвижения | нет | | | | promotion | TrxPromotion | delete_trx_promo_open_api_cancel | DeleteTrxPromoOpenApiCancelRequest | DeleteTrxPromoOpenApiCancelResponse | контракт+маппинг | | -| promotion | TrxPromo.json | GET | /trx-promo/1/commissions | Проверка доступности продвижения и размера комиссий | нет | | | | promotion | TrxPromotion | get_trx_promo_open_api_commissions | GetTrxPromoOpenApiCommissionsRequest | GetTrxPromoOpenApiCommissionsResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | POST | /job/v1/applications/apply_actions | Батчевая смена статуса откликов | нет | | | | jobs | Application | get_applications_apply_actions | GetApplicationsApplyActionsRequest | GetApplicationsApplyActionsResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | POST | /job/v1/applications/get_by_ids | Получение списка откликов | нет | | | | jobs | Application | list_applications_get_by_ids | ListApplicationsGetByIdsRequest | ListApplicationsGetByIdsResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | GET | /job/v1/applications/get_ids | Получение идентификаторов откликов | нет | | | | jobs | Application | list_applications_get_ids | NoRequest | ListApplicationsGetIdsResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | GET | /job/v1/applications/get_states | Получение списка возможных статусов откликов | нет | | | | jobs | Application | list_applications_get_states | NoRequest | ListApplicationsGetStatesResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | POST | /job/v1/applications/set_is_viewed | Изменение статуса отклика | нет | | | | jobs | Application | get_applications_set_is_viewed | GetApplicationsSetIsViewedRequest | GetApplicationsSetIsViewedResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | DELETE | /job/v1/applications/webhook | Отключение уведомлений по откликам (webhook) | нет | | | | jobs | JobWebhook | delete_applications_webhook_delete | NoRequest | DeleteApplicationsWebhookDeleteResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | GET | /job/v1/applications/webhook | Получение информации о подписках (webhook) | нет | | | | jobs | JobWebhook | get_applications_webhook_get | NoRequest | GetApplicationsWebhookGetResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | PUT | /job/v1/applications/webhook | Включение уведомлений по откликам (webhook) | нет | | | | jobs | JobWebhook | update_applications_webhook_put | UpdateApplicationsWebhookPutRequest | UpdateApplicationsWebhookPutResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | GET | /job/v1/applications/webhooks | Получение списка подписок (webhook) | нет | | | | jobs | JobWebhook | list_applications_webhooks_get | NoRequest | ListApplicationsWebhooksGetResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | GET | /job/v1/resumes/ | Поиск резюме | нет | | | | jobs | Resume | list_resumes_get | NoRequest | ListResumesGetResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | GET | /job/v1/resumes/{resume_id}/contacts/ | Доступ к контактным данным соискателя | нет | | | | jobs | Resume | get_resume_get_contacts | NoRequest | GetResumeGetContactsResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | POST | /job/v1/vacancies | Публикация вакансии | нет | | | | jobs | Vacancy | create_vacancy_create | CreateVacancyCreateRequest | CreateVacancyCreateResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | PUT | /job/v1/vacancies/archived/{vacancy_id} | Остановка публикации вакансии | нет | | | | jobs | Vacancy | delete_vacancy_archive | DeleteVacancyArchiveRequest | EmptyResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | PUT | /job/v1/vacancies/{vacancy_id} | Редактирование вакансии | нет | | | | jobs | Vacancy | update_vacancy_update | UpdateVacancyUpdateRequest | EmptyResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | POST | /job/v1/vacancies/{vacancy_id}/prolongate | Реактивация вакансии | нет | | | | jobs | Vacancy | create_vacancy_prolongate | CreateVacancyProlongateRequest | EmptyResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | GET | /job/v2/resumes/{resume_id} | Просмотр данных резюме | нет | | | | jobs | Resume | get_resume_get_item | NoRequest | GetResumeGetItemResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | GET | /job/v2/vacancies | Поиск вакансий | нет | | | | jobs | Vacancy | list_search_vacancy | NoRequest | ListSearchVacancyResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | POST | /job/v2/vacancies | Публикация вакансии v2 | нет | | | | jobs | Vacancy | create_vacancy_create_v2 | CreateVacancyCreateV2Request | CreateVacancyCreateV2Response | контракт+маппинг | | -| jobs | АвитоРабота.json | POST | /job/v2/vacancies/batch | Просмотр данных вакансий | нет | | | | jobs | Vacancy | get_vacancies_get_by_ids | GetVacanciesGetByIdsRequest | GetVacanciesGetByIdsResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | POST | /job/v2/vacancies/statuses | Получение статуса публикации вакансий V2 | нет | | | | jobs | Vacancy | get_vacancy_get_statuses | GetVacancyGetStatusesRequest | GetVacancyGetStatusesResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | POST | /job/v2/vacancies/update/{vacancy_uuid} | Редактирование вакансии v2 | нет | | | | jobs | Vacancy | update_vacancy_update_v2 | UpdateVacancyUpdateV2Request | UpdateVacancyUpdateV2Response | контракт+маппинг | | -| jobs | АвитоРабота.json | GET | /job/v2/vacancies/{vacancy_id} | Просмотр данных вакансии | нет | | | | jobs | Vacancy | get_vacancy_get_item | NoRequest | GetVacancyGetItemResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | PUT | /job/v2/vacancies/{vacancy_uuid}/auto_renewal | Автопродление вакансии v2 | нет | | | | jobs | Vacancy | update_vacancy_auto_renewal | UpdateVacancyAutoRenewalRequest | EmptyResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | GET | /job/v2/vacancy/dict | Получение списка доступных словарей | нет | | | | jobs | JobDictionary | list_dicts | NoRequest | ListDictsResponse | контракт+маппинг | | -| jobs | АвитоРабота.json | GET | /job/v2/vacancy/dict/{dictionary_id} | Получение доступных значений списка по ID словаря | нет | | | | jobs | JobDictionary | list_dict_by_id | NoRequest | ListDictByIdResponse | контракт+маппинг | | -| ads | Автозагрузка.json | GET | /autoload/v1/profile | Получение профиля пользователя автозагрузки (deprecated) | да | 1.1.0 | autoload_profile().get | 1.3.0 | ads | AutoloadArchive | get_profile | NoRequest | LegacyGetProfileResponse | контракт+маппинг | | -| ads | Автозагрузка.json | POST | /autoload/v1/profile | Создание/редактирование настроек профиля пользователя автозагрузки (deprecated) | да | 1.1.0 | autoload_profile().save | 1.3.0 | ads | AutoloadArchive | save_profile | LegacyCreateOrUpdateProfileRequest | EmptyResponse | контракт+маппинг | | -| ads | Автозагрузка.json | POST | /autoload/v1/upload | Загрузка файла по ссылке | нет | | | | ads | AutoloadProfile | create_upload | NoRequest | EmptyResponse | контракт+маппинг | | -| ads | Автозагрузка.json | GET | /autoload/v1/user-docs/node/{node_slug}/fields | Получения полей категории | нет | | | | ads | AutoloadProfile | get_user_docs_node_fields | NoRequest | GetUserDocsNodeFieldsResponse | контракт+маппинг | | -| ads | Автозагрузка.json | GET | /autoload/v1/user-docs/tree | Получение дерева категорий | нет | | | | ads | AutoloadProfile | get_user_docs_tree | NoRequest | GetUserDocsTreeResponse | контракт+маппинг | | -| ads | Автозагрузка.json | GET | /autoload/v2/items/ad_ids | ID объявлений из файла | нет | | | | ads | AutoloadReport | get_ad_ids_by_avito_ids | NoRequest | GetAdIdsByAvitoIdsResponse | контракт+маппинг | | -| ads | Автозагрузка.json | GET | /autoload/v2/items/avito_ids | ID объявлений на Авито | нет | | | | ads | AutoloadReport | get_avito_ids_by_ad_ids | NoRequest | GetAvitoIdsByAdIdsResponse | контракт+маппинг | | -| ads | Автозагрузка.json | GET | /autoload/v2/profile | Получение профиля пользователя автозагрузки | нет | | | | ads | AutoloadProfile | get_profile_v2 | NoRequest | GetProfileV2Response | контракт+маппинг | | -| ads | Автозагрузка.json | POST | /autoload/v2/profile | Создание/редактирование настроек профиля пользователя автозагрузки | нет | | | | ads | AutoloadProfile | create_or_update_profile_v2 | CreateOrUpdateProfileV2Request | EmptyResponse | контракт+маппинг | | -| ads | Автозагрузка.json | GET | /autoload/v2/reports | Список отчётов автозагрузки | нет | | | | ads | AutoloadReport | list_reports_v2 | NoRequest | ListReportsV2Response | контракт+маппинг | | -| ads | Автозагрузка.json | GET | /autoload/v2/reports/items | Объявления по ID в автозагрузке | нет | | | | ads | AutoloadReport | get_autoload_items_info_v2 | NoRequest | GetAutoloadItemsInfoV2Response | контракт+маппинг | | -| ads | Автозагрузка.json | GET | /autoload/v2/reports/last_completed_report | Статистика по последней выгрузке (deprecated) | да | 1.1.0 | autoload_report().get_last_completed | 1.3.0 | ads | AutoloadArchive | get_last_completed_report | NoRequest | LegacyGetLastCompletedReportResponse | контракт+маппинг | | -| ads | Автозагрузка.json | GET | /autoload/v2/reports/{report_id} | Статистика по конкретной выгрузке (deprecated) | да | 1.1.0 | autoload_report().get | 1.3.0 | ads | AutoloadArchive | get_report | NoRequest | LegacyGetReportByIdV2Response | контракт+маппинг | | -| ads | Автозагрузка.json | GET | /autoload/v2/reports/{report_id}/items | Все объявления из конкретной выгрузки | нет | | | | ads | AutoloadReport | get_report_items_by_id | NoRequest | GetReportItemsByIdResponse | контракт+маппинг | | -| ads | Автозагрузка.json | GET | /autoload/v2/reports/{report_id}/items/fees | Списания за объявления в конкретной выгрузке | нет | | | | ads | AutoloadReport | get_report_items_fees_by_id | NoRequest | GetReportItemsFeesByIdResponse | контракт+маппинг | | -| ads | Автозагрузка.json | GET | /autoload/v3/reports/last_completed_report | Статистика по последней выгрузке | нет | | | | ads | AutoloadReport | get_last_completed_report_v3 | NoRequest | GetLastCompletedReportV3Response | контракт+маппинг | | -| ads | Автозагрузка.json | GET | /autoload/v3/reports/{report_id} | Статистика по конкретной выгрузке | нет | | | | ads | AutoloadReport | get_report_by_id_v3 | NoRequest | GetReportByIdV3Response | контракт+маппинг | | -| auth | Авторизация.json | POST | /token | Получение access token | нет | | | | auth | AvitoClient.auth() | get_access_token | GetAccessTokenRequest | GetAccessTokenResponse | контракт+маппинг | канонический token-endpoint | -| auth | Авторизация.json | POST | /token | Получение access token | нет | | | | auth | AvitoClient.auth() | get_access_token_authorization_code | GetAccessTokenAuthorizationCodeRequest | GetAccessTokenAuthorizationCodeResponse | контракт+маппинг | нормализованы скрытые Unicode-символы в пути /token для inventory | -| auth | Авторизация.json | POST | /token | Обновление access token | нет | | | | auth | AvitoClient.auth() | update_refresh_access_token_authorization_code | UpdateRefreshAccessTokenAuthorizationCodeRequest | UpdateRefreshAccessTokenAuthorizationCodeResponse | контракт+маппинг | нормализованы скрытые Unicode-символы в пути /token для inventory | -| promotion | Автостратегия.json | POST | /autostrategy/v1/budget | Расчет бюджета кампании | нет | | | | promotion | AutostrategyCampaign | create_autostrategy_budget | CreateAutostrategyBudgetRequest | CreateAutostrategyBudgetResponse | контракт+маппинг | | -| promotion | Автостратегия.json | POST | /autostrategy/v1/campaign/create | Создание новой кампании | нет | | | | promotion | AutostrategyCampaign | create_autostrategy_campaign | CreateAutostrategyCampaignRequest | CreateAutostrategyCampaignResponse | контракт+маппинг | | -| promotion | Автостратегия.json | POST | /autostrategy/v1/campaign/edit | Редактирование кампании | нет | | | | promotion | AutostrategyCampaign | update_edit_autostrategy_campaign | UpdateEditAutostrategyCampaignRequest | UpdateEditAutostrategyCampaignResponse | контракт+маппинг | | -| promotion | Автостратегия.json | POST | /autostrategy/v1/campaign/info | Получение полной информации о кампании | нет | | | | promotion | AutostrategyCampaign | get_autostrategy_campaign_info | GetAutostrategyCampaignInfoRequest | GetAutostrategyCampaignInfoResponse | контракт+маппинг | | -| promotion | Автостратегия.json | POST | /autostrategy/v1/campaign/stop | Остановка кампании | нет | | | | promotion | AutostrategyCampaign | delete_stop_autostrategy_campaign | DeleteStopAutostrategyCampaignRequest | DeleteStopAutostrategyCampaignResponse | контракт+маппинг | | -| promotion | Автостратегия.json | POST | /autostrategy/v1/campaigns | Получение списка кампаний | нет | | | | promotion | AutostrategyCampaign | list_autostrategy_campaigns | ListAutostrategyCampaignsRequest | ListAutostrategyCampaignsResponse | контракт+маппинг | | -| promotion | Автостратегия.json | POST | /autostrategy/v1/stat | Получение статистики по кампании | нет | | | | promotion | AutostrategyCampaign | get_autostrategy_stat | GetAutostrategyStatRequest | GetAutostrategyStatResponse | контракт+маппинг | | -| autoteka | Автотека.json | POST | /autoteka/v1/catalogs/resolve | Получение актуальных параметров Автокаталога | нет | | | | autoteka | AutotekaVehicle | get_catalogs_resolve | GetCatalogsResolveRequest | GetCatalogsResolveResponse | контракт+маппинг | | -| autoteka | Автотека.json | POST | /autoteka/v1/get-leads/ | Получение событий сервиса Сигнал | нет | | | | autoteka | AutotekaVehicle | get_leads | GetLeadsRequest | GetLeadsResponse | контракт+маппинг | | -| autoteka | Автотека.json | POST | /autoteka/v1/monitoring/bucket/add | Добавить идентификаторы (vin/frame) на мониторинг | нет | | | | autoteka | AutotekaMonitoring | create_monitoring_bucket_add | CreateMonitoringBucketAddRequest | CreateMonitoringBucketAddResponse | контракт+маппинг | | -| autoteka | Автотека.json | POST | /autoteka/v1/monitoring/bucket/delete | Полная очистка списка мониторинга | нет | | | | autoteka | AutotekaMonitoring | list_monitoring_bucket_delete | NoRequest | ListMonitoringBucketDeleteResponse | контракт+маппинг | | -| autoteka | Автотека.json | POST | /autoteka/v1/monitoring/bucket/remove | Удаление идентификаторов из мониторинга (vin/frame) | нет | | | | autoteka | AutotekaMonitoring | delete_monitoring_bucket_remove | DeleteMonitoringBucketRemoveRequest | DeleteMonitoringBucketRemoveResponse | контракт+маппинг | | -| autoteka | Автотека.json | GET | /autoteka/v1/monitoring/get-reg-actions/ | Получение событий мониторинга | нет | | | | autoteka | AutotekaMonitoring | get_monitoring_get_reg_actions | NoRequest | GetMonitoringGetRegActionsResponse | контракт+маппинг | | -| autoteka | Автотека.json | GET | /autoteka/v1/packages/active_package | Запрос остатка отчётов пользователя | нет | | | | autoteka | AutotekaReport | get_active_package | NoRequest | GetActivePackageResponse | контракт+маппинг | | -| autoteka | Автотека.json | POST | /autoteka/v1/previews | Превью по VIN или номеру кузова | нет | | | | autoteka | AutotekaVehicle | create_preview_by_vin | CreatePreviewByVinRequest | CreatePreviewByVinResponse | контракт+маппинг | | -| autoteka | Автотека.json | GET | /autoteka/v1/previews/{previewId} | Получение превью по его ID | нет | | | | autoteka | AutotekaVehicle | get_preview | NoRequest | GetPreviewResponse | контракт+маппинг | | -| autoteka | Автотека.json | POST | /autoteka/v1/reports | Отчет по превью | нет | | | | autoteka | AutotekaReport | create_report | CreateReportRequest | CreateReportResponse | контракт+маппинг | | -| autoteka | Автотека.json | POST | /autoteka/v1/reports-by-vehicle-id | Отчет по идентификатору авто (vin/frame) | нет | | | | autoteka | AutotekaReport | create_report_by_vehicle_id | CreateReportByVehicleIdRequest | CreateReportByVehicleIdResponse | контракт+маппинг | | -| autoteka | Автотека.json | GET | /autoteka/v1/reports/list/ | Получение списка отчётов | нет | | | | autoteka | AutotekaReport | list_report_list | NoRequest | ListReportListResponse | контракт+маппинг | | -| autoteka | Автотека.json | GET | /autoteka/v1/reports/{report_id} | Получение отчета по его ID | нет | | | | autoteka | AutotekaReport | get_report | NoRequest | GetReportResponse | контракт+маппинг | | -| autoteka | Автотека.json | POST | /autoteka/v1/request-preview-by-external-item | Превью по ID объявления другой площадки | нет | | | | autoteka | AutotekaVehicle | create_preview_by_external_item | CreatePreviewByExternalItemRequest | CreatePreviewByExternalItemResponse | контракт+маппинг | | -| autoteka | Автотека.json | POST | /autoteka/v1/request-preview-by-item-id | Превью по ID объявления Авито | нет | | | | autoteka | AutotekaVehicle | create_preview_by_item_id | CreatePreviewByItemIdRequest | CreatePreviewByItemIdResponse | контракт+маппинг | | -| autoteka | Автотека.json | POST | /autoteka/v1/request-preview-by-regnumber | Превью по государственному номеру | нет | | | | autoteka | AutotekaVehicle | create_preview_by_reg_number | CreatePreviewByRegNumberRequest | CreatePreviewByRegNumberResponse | контракт+маппинг | | -| autoteka | Автотека.json | POST | /autoteka/v1/scoring/by-vehicle-id | Скоринг рисков по идентификатору авто (vin/frame) | нет | | | | autoteka | AutotekaScoring | create_scoring_by_vehicle_id | CreateScoringByVehicleIdRequest | CreateScoringByVehicleIdResponse | контракт+маппинг | | -| autoteka | Автотека.json | GET | /autoteka/v1/scoring/{scoring_id} | Получение скоринга рисков по его ID | нет | | | | autoteka | AutotekaScoring | get_scoring_get_by_id | NoRequest | GetScoringGetByIdResponse | контракт+маппинг | | -| autoteka | Автотека.json | POST | /autoteka/v1/specifications/by-plate-number | Запрос характеристик по регистрационному номеру | нет | | | | autoteka | AutotekaVehicle | create_specification_by_plate_number | CreateSpecificationByPlateNumberRequest | CreateSpecificationByPlateNumberResponse | контракт+маппинг | | -| autoteka | Автотека.json | POST | /autoteka/v1/specifications/by-vehicle-id | Запрос характеристик по идентификатору авто (vin/frame) | нет | | | | autoteka | AutotekaVehicle | create_specification_by_vehicle_id | CreateSpecificationByVehicleIdRequest | CreateSpecificationByVehicleIdResponse | контракт+маппинг | | -| autoteka | Автотека.json | GET | /autoteka/v1/specifications/specification/{specificationID} | Получение характеристик по ID запроса | нет | | | | autoteka | AutotekaVehicle | get_specification_get_by_id | NoRequest | GetSpecificationGetByIdResponse | контракт+маппинг | | -| autoteka | Автотека.json | POST | /autoteka/v1/sync/create-by-regnumber | Синхронное создание отчета по ГРЗ | нет | | | | autoteka | AutotekaReport | create_sync_create_report_by_reg_number | CreateSyncCreateReportByRegNumberRequest | CreateSyncCreateReportByRegNumberResponse | контракт+маппинг | | -| autoteka | Автотека.json | POST | /autoteka/v1/sync/create-by-vin | Синхронное создание отчёта по VIN или номеру кузова | нет | | | | autoteka | AutotekaReport | create_sync_create_report_by_vin | CreateSyncCreateReportByVinRequest | CreateSyncCreateReportByVinResponse | контракт+маппинг | | -| autoteka | Автотека.json | POST | /autoteka/v1/teasers | Тизер по идентификатору авто (vin/frame) | нет | | | | autoteka | AutotekaVehicle | create_teaser | CreateTeaserRequest | CreateTeaserResponse | контракт+маппинг | | -| autoteka | Автотека.json | GET | /autoteka/v1/teasers/{teaser_id} | Получение тизера по ID тизера | нет | | | | autoteka | AutotekaVehicle | get_teaser | NoRequest | GetTeaserResponse | контракт+маппинг | | -| autoteka | Автотека.json | POST | /autoteka/v1/valuation/by-specification | Получение оценки по параметрам | нет | | | | autoteka | AutotekaValuation | get_valuation_by_specification | GetValuationBySpecificationRequest | GetValuationBySpecificationResponse | контракт+маппинг | | -| autoteka | Автотека.json | POST | /token | Получение access token | нет | | | | autoteka | AvitoClient.auth() | get_access_token | NoRequest | GetAccessTokenResponse | контракт+маппинг | | -| realty | Аналитикапонедвижимости.json | GET | /realty/v1/marketPriceCorrespondence/{itemId}/{price} | Получение соответствия переданной цены рыночной цене | нет | | | | realty | RealtyAnalyticsReport | get_market_price_correspondence_v1 | NoRequest | GetMarketPriceCorrespondenceV1Response | контракт+маппинг | | -| realty | Аналитикапонедвижимости.json | POST | /realty/v1/report/create/{itemId} | Получение аналитического отчета по недвижимости | нет | | | | realty | RealtyAnalyticsReport | get_report_for_classified | NoRequest | GetReportForClassifiedResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /cancelAnnouncement | Отмена анонса в СД | нет | | | | orders | DeliveryOrder | delete_cancel_announcement3_pl | DeleteCancelAnnouncement3PlRequest | DeleteCancelAnnouncement3PlResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /createAnnouncement | Создание анонса в СД | нет | | | | orders | DeliveryOrder | create_announcement3_pl | CreateAnnouncement3PlRequest | CreateAnnouncement3PlResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /createParcel | Создание посылки | нет | | | | orders | DeliveryOrder | create_parcel | CreateParcelRequest | CreateParcelResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/announcements/create | Создание анонса в Avito | нет | | | | orders | SandboxDelivery | create_announcement | CreateAnnouncementRequest | CreateAnnouncementResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/announcements/track | Трекинг анонсов | нет | | | | orders | SandboxDelivery | create_track_announcement | CreateTrackAnnouncementRequest | CreateTrackAnnouncementResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/areas/custom-schedule | Установка графика работы на определённый день | нет | | | | orders | SandboxDelivery | update_custom_area_schedule | UpdateCustomAreaScheduleRequest | UpdateCustomAreaScheduleResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/cancelParcel | Отмена посылки | нет | | | | orders | SandboxDelivery | delete_cancel_parcel | DeleteCancelParcelRequest | DeleteCancelParcelResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/order/checkConfirmationCode | Проверка кода подтверждения | нет | | | | orders | SandboxDelivery | get_check_confirmation_code | GetCheckConfirmationCodeRequest | GetCheckConfirmationCodeResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/order/properties | Добавление / изменение параметров доставки посылки | нет | | | | orders | SandboxDelivery | create_set_order_properties | CreateSetOrderPropertiesRequest | CreateSetOrderPropertiesResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/order/realAddress | Фактический адрес приёма / возврата посылки | нет | | | | orders | SandboxDelivery | create_set_order_real_address | CreateSetOrderRealAddressRequest | CreateSetOrderRealAddressResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/order/tracking | Трекинг | нет | | | | orders | SandboxDelivery | create_tracking | CreateTrackingRequest | CreateTrackingResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/prohibitOrderAcceptance | Запрет приёма посылки от отправителя | нет | | | | orders | SandboxDelivery | delete_prohibit_order_acceptance | DeleteProhibitOrderAcceptanceRequest | DeleteProhibitOrderAcceptanceResponse | контракт+маппинг | | -| orders | Доставка.json | GET | /delivery-sandbox/sorting-center | Получить список сортировочных центров | нет | | | | orders | SandboxDelivery | list_sorting_center | NoRequest | ListSortingCenterResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/tariffs/sorting-center | Загрузить сортировочные центры | нет | | | | orders | SandboxDelivery | create_add_sorting_center | CreateAddSortingCenterRequest | CreateAddSortingCenterResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/tariffs/{tariff_id}/areas | Загрузить области доставки | нет | | | | orders | SandboxDelivery | create_add_areas_sandbox | CreateAddAreasSandboxRequest | CreateAddAreasSandboxResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/tariffs/{tariff_id}/tagged-sorting-centers | Установка тэгов своим и/или чужим сортировочным центрам | нет | | | | orders | SandboxDelivery | update_add_tags_to_sorting_center | UpdateAddTagsToSortingCenterRequest | UpdateAddTagsToSortingCenterResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/tariffs/{tariff_id}/terminals | Загрузить терминалы | нет | | | | orders | SandboxDelivery | create_add_terminals_sandbox | CreateAddTerminalsSandboxRequest | CreateAddTerminalsSandboxResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/tariffs/{tariff_id}/terms | Обновить сроки по тарифу | нет | | | | orders | SandboxDelivery | update_update_terms | UpdateUpdateTermsRequest | UpdateUpdateTermsResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/tariffsV2 | Загрузить новый тариф v2 | нет | | | | orders | SandboxDelivery | create_add_tariff_sandbox_v2 | CreateAddTariffSandboxV2Request | CreateAddTariffSandboxV2Response | контракт+маппинг | | -| orders | Доставка.json | GET | /delivery-sandbox/tasks/{task_id} | Получение информации по задаче | нет | | | | orders | DeliveryTask | get_task | NoRequest | GetTaskResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/v1/cancelAnnouncement | Отправка события об отмене тестового анонса | нет | | | | orders | SandboxDelivery | create_v1cancel_announcement | CreateV1cancelAnnouncementRequest | CreateV1cancelAnnouncementResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/v1/cancelParcel | Отмена тестовой посылки | нет | | | | orders | SandboxDelivery | delete_v1_cancel_parcel | DeleteV1CancelParcelRequest | DeleteV1CancelParcelResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/v1/changeParcel | Создание заявки на изменение данных тестовой посылки | нет | | | | orders | SandboxDelivery | create_v1change_parcel | CreateV1changeParcelRequest | CreateV1changeParcelResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/v1/createAnnouncement | Создание тестового анонса | нет | | | | orders | SandboxDelivery | create_v1create_announcement | CreateV1createAnnouncementRequest | CreateV1createAnnouncementResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/v1/getAnnouncementEvent | Получение последнего события тестового анонса | нет | | | | orders | SandboxDelivery | get_v1get_announcement_event | GetV1getAnnouncementEventRequest | GetV1getAnnouncementEventResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/v1/getChangeParcelInfo | Получение информации об изменении тестовой посылки | нет | | | | orders | SandboxDelivery | get_v1get_change_parcel_info | GetV1getChangeParcelInfoRequest | GetV1getChangeParcelInfoResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/v1/getParcelInfo | Получение информации о тестовой посылке | нет | | | | orders | SandboxDelivery | get_v1get_parcel_info | GetV1getParcelInfoRequest | GetV1getParcelInfoResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/v1/getRegisteredParcelID | Получение ID зарегистрированной тестовой посылки | нет | | | | orders | SandboxDelivery | get_v1get_registered_parcel_id | GetV1getRegisteredParcelIdRequest | GetV1getRegisteredParcelIdResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery-sandbox/v2/createParcel | Создание тестовой посылки | нет | | | | orders | SandboxDelivery | create_sandbox_parcel_v2 | CreateSandboxParcelV2Request | CreateSandboxParcelV2Response | контракт+маппинг | | -| orders | Доставка.json | POST | /delivery/order/changeParcelResult | Отправка результата исполнения заявки | нет | | | | orders | DeliveryOrder | create_change_parcel_result | CreateChangeParcelResultRequest | CreateChangeParcelResultResponse | контракт+маппинг | | -| orders | Доставка.json | POST | /sandbox/changeParcels | Обновление свойств посылок | нет | | | | orders | DeliveryOrder | update_change_parcels | UpdateChangeParcelsRequest | UpdateChangeParcelsResponse | контракт+маппинг | | -| accounts | ИерархияАккаунтов.json | GET | /checkAhUserV1 | Получение информации о статусе пользователя в ИА | нет | | | | accounts | AccountHierarchy | get_check_ah_user_v1 | NoRequest | GetCheckAhUserV1Response | контракт+маппинг | | -| accounts | ИерархияАккаунтов.json | GET | /getEmployeesV1 | Получение списка сотрудников иерархии | нет | | | | accounts | AccountHierarchy | list_employees_v1 | NoRequest | ListEmployeesV1Response | контракт+маппинг | | -| accounts | ИерархияАккаунтов.json | POST | /linkItemsV1 | Прикрепление сотрудника иерархии к объявлениям, перезакрепление объявлений между сотрудниками иерархии | нет | | | | accounts | AccountHierarchy | create_link_items_v1 | CreateLinkItemsV1Request | EmptyResponse | контракт+маппинг | | -| accounts | ИерархияАккаунтов.json | GET | /listCompanyPhonesV1 | Получение списка телефонов компании | нет | | | | accounts | AccountHierarchy | list_company_phones_v1 | NoRequest | ListCompanyPhonesV1Response | контракт+маппинг | | -| accounts | ИерархияАккаунтов.json | POST | /listItemsByEmployeeIdV1 | Получение списка объявлений по сотруднику | нет | | | | accounts | AccountHierarchy | list_items_by_employee_id_v1 | ListItemsByEmployeeIdV1Request | ListItemsByEmployeeIdV1Response | контракт+маппинг | | -| accounts | Информацияопользователе.json | POST | /core/v1/accounts/operations_history/ | Получение истории операций пользователя | нет | | | | accounts | Account | get_operations_history | GetOperationsHistoryRequest | GetOperationsHistoryResponse | контракт+маппинг | | -| accounts | Информацияопользователе.json | GET | /core/v1/accounts/self | Получение информации об авторизованном пользователе | нет | | | | accounts | Account | get_user_info_self | NoRequest | GetUserInfoSelfResponse | контракт+маппинг | | -| accounts | Информацияопользователе.json | GET | /core/v1/accounts/{user_id}/balance/ | Получение баланса кошелька пользователя | нет | | | | accounts | Account | get_user_balance | NoRequest | GetUserBalanceResponse | контракт+маппинг | | -| realty | Краткосрочнаяаренда.json | POST | /core/v1/accounts/{user_id}/items/{item_id}/bookings | Заполнение календаря занятости объекта недвижимости | нет | | | | realty | RealtyBooking | update_bookings_info | UpdateBookingsInfoRequest | UpdateBookingsInfoResponse | контракт+маппинг | | -| realty | Краткосрочнаяаренда.json | GET | /realty/v1/accounts/{user_id}/items/{item_id}/bookings | Получение списка броней по объявлению | нет | | | | realty | RealtyBooking | list_realty_bookings | NoRequest | ListRealtyBookingsResponse | контракт+маппинг | | -| realty | Краткосрочнаяаренда.json | POST | /realty/v1/accounts/{user_id}/items/{item_id}/prices | Актуализация параметров для выбранных периодов | нет | | | | realty | RealtyPricing | update_realty_prices | UpdateRealtyPricesRequest | UpdateRealtyPricesResponse | контракт+маппинг | | -| realty | Краткосрочнаяаренда.json | POST | /realty/v1/items/intervals | Заполнение доступности объекта недвижимости с квотами и без | нет | | | | realty | RealtyListing | get_intervals | GetIntervalsRequest | GetIntervalsResponse | контракт+маппинг | | -| realty | Краткосрочнаяаренда.json | POST | /realty/v1/items/{item_id}/base | Установка базовых параметров | нет | | | | realty | RealtyListing | update_base_params | UpdateBaseParamsRequest | EmptyResponse | контракт+маппинг | | -| messenger | Мессенджер.json | POST | /messenger/v1/accounts/{user_id}/chats/{chat_id}/messages | Отправка сообщения | нет | | | | messenger | ChatMessage | create_send_message | CreateSendMessageRequest | CreateSendMessageResponse | контракт+маппинг | | -| messenger | Мессенджер.json | POST | /messenger/v1/accounts/{user_id}/chats/{chat_id}/messages/image | Отправка сообщения с изображением | нет | | | | messenger | ChatMessage | create_send_image_message | CreateSendImageMessageRequest | CreateSendImageMessageResponse | контракт+маппинг | | -| messenger | Мессенджер.json | POST | /messenger/v1/accounts/{user_id}/chats/{chat_id}/messages/{message_id} | Удаление сообщения | нет | | | | messenger | ChatMessage | delete_message | NoRequest | DeleteMessageResponse | контракт+маппинг | | -| messenger | Мессенджер.json | POST | /messenger/v1/accounts/{user_id}/chats/{chat_id}/read | Прочитать чат | нет | | | | messenger | Chat | create_chat_read | NoRequest | CreateChatReadResponse | контракт+маппинг | | -| messenger | Мессенджер.json | GET | /messenger/v1/accounts/{user_id}/getVoiceFiles | Получение голосовых сообщений | нет | | | | messenger | ChatMedia | get_voice_files | NoRequest | GetVoiceFilesResponse | контракт+маппинг | | -| messenger | Мессенджер.json | POST | /messenger/v1/accounts/{user_id}/uploadImages | Загрузка изображений | нет | | | | messenger | ChatMedia | create_upload_images | CreateUploadImagesMultipartRequest | CreateUploadImagesResponse | контракт+маппинг | | -| messenger | Мессенджер.json | POST | /messenger/v1/subscriptions | Получение подписок (webhooks) | нет | | | | messenger | ChatWebhook | get_subscriptions | NoRequest | GetSubscriptionsResponse | контракт+маппинг | | -| messenger | Мессенджер.json | POST | /messenger/v1/webhook/unsubscribe | Отключение уведомлений (webhooks) | нет | | | | messenger | ChatWebhook | delete_webhook_unsubscribe | DeleteWebhookUnsubscribeRequest | DeleteWebhookUnsubscribeResponse | контракт+маппинг | | -| messenger | Мессенджер.json | POST | /messenger/v2/accounts/{user_id}/blacklist | Добавление пользователя в blacklist | нет | | | | messenger | Chat | create_blacklist_v2 | CreateBlacklistV2Request | EmptyResponse | контракт+маппинг | | -| messenger | Мессенджер.json | GET | /messenger/v2/accounts/{user_id}/chats | Получение информации по чатам | нет | | | | messenger | Chat | get_chats_v2 | NoRequest | GetChatsV2Response | контракт+маппинг | | -| messenger | Мессенджер.json | GET | /messenger/v2/accounts/{user_id}/chats/{chat_id} | Получение информации по чату | нет | | | | messenger | Chat | get_chat_by_id_v2 | NoRequest | GetChatByIdV2Response | контракт+маппинг | | -| messenger | Мессенджер.json | GET | /messenger/v3/accounts/{user_id}/chats/{chat_id}/messages/ | Получение списка сообщений V3 | нет | | | | messenger | ChatMessage | list_messages_v3 | NoRequest | ListMessagesV3Response | контракт+маппинг | | -| messenger | Мессенджер.json | POST | /messenger/v3/webhook | Включение уведомлений V3 (webhooks) | нет | | | | messenger | ChatWebhook | update_webhook_v3 | UpdateWebhookV3Request | UpdateWebhookV3Response | контракт+маппинг | | -| promotion | Настройкаценыцелевогодействия.json | GET | /cpxpromo/1/getBids/{itemId} | Получение детализированной информации о действующих и доступных ценах за целевые действия и бюджетах | нет | | | | promotion | TargetActionPricing | get_bids | NoRequest | GetBidsResponse | контракт+маппинг | | -| promotion | Настройкаценыцелевогодействия.json | POST | /cpxpromo/1/getPromotionsByItemIds | Получение текущих цен за целевое действие и бюджетов по нескольким объявлениям | нет | | | | promotion | TargetActionPricing | get_promotions_by_item_ids | GetPromotionsByItemIdsRequest | GetPromotionsByItemIdsResponse | контракт+маппинг | | -| promotion | Настройкаценыцелевогодействия.json | POST | /cpxpromo/1/remove | Остановка продвижения | нет | | | | promotion | TargetActionPricing | delete_promotion | DeletePromotionRequest | DeletePromotionResponse | контракт+маппинг | | -| promotion | Настройкаценыцелевогодействия.json | POST | /cpxpromo/1/setAuto | Применение автоматической настройки | нет | | | | promotion | TargetActionPricing | update_auto_bid | UpdateAutoBidRequest | EmptyResponse | контракт+маппинг | | -| promotion | Настройкаценыцелевогодействия.json | POST | /cpxpromo/1/setManual | Применение ручной настройки | нет | | | | promotion | TargetActionPricing | update_manual_bid | UpdateManualBidRequest | EmptyResponse | контракт+маппинг | | -| ads | Объявления.json | POST | /core/v1/accounts/{userId}/vas/prices | Получение информации о стоимости услуг продвижения и доступных значках | нет | | | | ads | AdPromotion | get_vas_prices | GetVasPricesRequest | GetVasPricesResponse | контракт+маппинг | | -| ads | Объявления.json | POST | /core/v1/accounts/{user_id}/calls/stats/ | Получение статистики по звонкам | нет | | | | ads | AdStats | get_calls_stats | GetCallsStatsRequest | GetCallsStatsResponse | контракт+маппинг | | -| ads | Объявления.json | GET | /core/v1/accounts/{user_id}/items/{item_id}/ | Получение информации по объявлению | нет | | | | ads | Ad | get_item_info | NoRequest | GetItemInfoResponse | контракт+маппинг | | -| ads | Объявления.json | PUT | /core/v1/accounts/{user_id}/items/{item_id}/vas | Применение дополнительных услуг | нет | | | | ads | AdPromotion | update_item_vas | UpdateItemVasRequest | UpdateItemVasResponse | контракт+маппинг | | -| ads | Объявления.json | GET | /core/v1/items | Получение информации по объявлениям | нет | | | | ads | Ad | get_items_info | NoRequest | GetItemsInfoResponse | контракт+маппинг | | -| ads | Объявления.json | POST | /core/v1/items/{item_id}/update_price | Обновление цены объявления | нет | | | | ads | Ad | update_update_price | UpdateUpdatePriceRequest | UpdateUpdatePriceResponse | контракт+маппинг | | -| ads | Объявления.json | PUT | /core/v2/accounts/{user_id}/items/{item_id}/vas_packages | Применение пакета дополнительных услуг | нет | | | | ads | AdPromotion | update_item_vas_package_v2 | UpdateItemVasPackageV2Request | UpdateItemVasPackageV2Response | контракт+маппинг | | -| ads | Объявления.json | PUT | /core/v2/items/{itemId}/vas/ | Применение услуг продвижения | нет | | | | ads | AdPromotion | update_apply_vas | UpdateApplyVasRequest | UpdateApplyVasResponse | контракт+маппинг | | -| ads | Объявления.json | POST | /stats/v1/accounts/{user_id}/items | Получение статистики по списку объявлений | нет | | | | ads | AdStats | get_item_stats_shallow | GetItemStatsShallowRequest | GetItemStatsShallowResponse | контракт+маппинг | | -| ads | Объявления.json | POST | /stats/v2/accounts/{user_id}/items | Получение статистических показателей по профилю | нет | | | | ads | AdStats | get_item_analytics | GetItemAnalyticsRequest | GetItemAnalyticsResponse | контракт+маппинг | | -| ads | Объявления.json | POST | /stats/v2/accounts/{user_id}/spendings | Получение статистики расходов профиля | нет | | | | ads | AdStats | get_account_spendings | GetAccountSpendingsRequest | GetAccountSpendingsResponse | контракт+маппинг | | -| promotion | Продвижение.json | POST | /promotion/v1/items/services/bbip/forecasts/get | BBIP. Прогноз продвижения | нет | | | | promotion | BbipPromotion | create_bbip_forecasts_by_items_v1 | CreateBbipForecastsByItemsV1Request | CreateBbipForecastsByItemsV1Response | контракт+маппинг | | -| promotion | Продвижение.json | PUT | /promotion/v1/items/services/bbip/orders/create | BBIP. Подключение услуги продвижения | нет | | | | promotion | BbipPromotion | update_bbip_order_for_items_v1 | UpdateBbipOrderForItemsV1Request | UpdateBbipOrderForItemsV1Response | контракт+маппинг | | -| promotion | Продвижение.json | POST | /promotion/v1/items/services/bbip/suggests/get | BBIP. Варианты бюджета продвижения | нет | | | | promotion | BbipPromotion | create_bbip_suggests_by_items_v1 | CreateBbipSuggestsByItemsV1Request | CreateBbipSuggestsByItemsV1Response | контракт+маппинг | | -| promotion | Продвижение.json | POST | /promotion/v1/items/services/dict | Словарь типов услуг продвижения | нет | | | | promotion | PromotionOrder | create_dict_of_services_v1 | NoRequest | CreateDictOfServicesV1Response | контракт+маппинг | | -| promotion | Продвижение.json | POST | /promotion/v1/items/services/get | Список услуг продвижения | нет | | | | promotion | PromotionOrder | list_services_by_items_v1 | ListServicesByItemsV1Request | ListServicesByItemsV1Response | контракт+маппинг | | -| promotion | Продвижение.json | POST | /promotion/v1/items/services/orders/get | Список заявок | нет | | | | promotion | PromotionOrder | list_orders_by_user_v1 | ListOrdersByUserV1Request | ListOrdersByUserV1Response | контракт+маппинг | | -| promotion | Продвижение.json | POST | /promotion/v1/items/services/orders/status | Статус заявки | нет | | | | promotion | PromotionOrder | get_order_status_v1 | GetOrderStatusV1Request | GetOrderStatusV1Response | контракт+маппинг | | -| messenger | Рассылкаскидокиспецпредложенийвмессенджере.json | POST | /special-offers/v1/available | Получение информации об объявлениях | нет | | | | messenger | SpecialOfferCampaign | get_available | GetAvailableRequest | GetAvailableResponse | контракт+маппинг | | -| messenger | Рассылкаскидокиспецпредложенийвмессенджере.json | POST | /special-offers/v1/multiConfirm | Отправка и оплата рассылки | нет | | | | messenger | SpecialOfferCampaign | create_multi_confirm | CreateMultiConfirmRequest | CreateMultiConfirmResponse | контракт+маппинг | | -| messenger | Рассылкаскидокиспецпредложенийвмессенджере.json | POST | /special-offers/v1/multiCreate | Создание рассылки | нет | | | | messenger | SpecialOfferCampaign | create_multi_create | CreateMultiCreateRequest | CreateMultiCreateResponse | контракт+маппинг | | -| messenger | Рассылкаскидокиспецпредложенийвмессенджере.json | POST | /special-offers/v1/stats | Получение статистики | нет | | | | messenger | SpecialOfferCampaign | get_stats | GetStatsRequest | GetStatsResponse | контракт+маппинг | | -| messenger | Рассылкаскидокиспецпредложенийвмессенджере.json | POST | /special-offers/v1/tariffInfo | Получение информации о тарифе | нет | | | | messenger | SpecialOfferCampaign | get_tariff_info | NoRequest | GetTariffInfoResponse | контракт+маппинг | | -| ratings | Рейтингииотзывы.json | POST | /ratings/v1/answers | Отправка ответа на отзыв | нет | | | | ratings | ReviewAnswer | create_review_answer_v1 | CreateReviewAnswerV1Request | CreateReviewAnswerV1Response | контракт+маппинг | | -| ratings | Рейтингииотзывы.json | DELETE | /ratings/v1/answers/{answer_id} | Запрос на удаление ответа на отзыв | нет | | | | ratings | ReviewAnswer | delete_review_answer_v1 | NoRequest | DeleteReviewAnswerV1Response | контракт+маппинг | | -| ratings | Рейтингииотзывы.json | GET | /ratings/v1/info | Получение информации о рейтинге пользователя | нет | | | | ratings | RatingProfile | get_ratings_info_v1 | NoRequest | GetRatingsInfoV1Response | контракт+маппинг | | -| ratings | Рейтингииотзывы.json | GET | /ratings/v1/reviews | Получение списка активных отзывов на пользователя с пагинацией | нет | | | | ratings | Review | list_reviews_v1 | NoRequest | ListReviewsV1Response | контракт+маппинг | | -| tariffs | Тарифы.json | GET | /tariff/info/1 | Информация по тарифу | нет | | | | tariffs | Tariff | get_tariff_info | NoRequest | GetTariffInfoResponse | контракт+маппинг | | -| orders | Управлениезаказами.json | POST | /order-management/1/markings | Передача честного знака | нет | | | | orders | Order | update_markings | UpdateMarkingsRequest | UpdateMarkingsResponse | контракт+маппинг | | -| orders | Управлениезаказами.json | POST | /order-management/1/order/acceptReturnOrder | Выбор отделения отделения Почты России для получения возврата | нет | | | | orders | Order | create_accept_return_order | CreateAcceptReturnOrderRequest | CreateAcceptReturnOrderResponse | контракт+маппинг | | -| orders | Управлениезаказами.json | POST | /order-management/1/order/applyTransition | Изменение статуса заказа | нет | | | | orders | Order | get_apply_transition | GetApplyTransitionRequest | GetApplyTransitionResponse | контракт+маппинг | | -| orders | Управлениезаказами.json | POST | /order-management/1/order/checkConfirmationCode | Метод для проверки кода подтверждения заказа. | нет | | | | orders | Order | create_check_confirmation_code | CreateCheckConfirmationCodeRequest | CreateCheckConfirmationCodeResponse | контракт+маппинг | | -| orders | Управлениезаказами.json | POST | /order-management/1/order/cncSetDetails | Метод для подготовки заказа с самовывозом | нет | | | | orders | Order | create_cnc_set_details | CreateCncSetDetailsRequest | CreateCncSetDetailsResponse | контракт+маппинг | | -| orders | Управлениезаказами.json | GET | /order-management/1/order/getCourierDeliveryRange | Метод получения доступных временных промежутков приезда курьера | нет | | | | orders | Order | get_courier_delivery_range | NoRequest | GetCourierDeliveryRangeResponse | контракт+маппинг | | -| orders | Управлениезаказами.json | POST | /order-management/1/order/setCourierDeliveryRange | Метод выбора определённого доступного временного промежутка для приезда курьера | нет | | | | orders | Order | get_set_courier_delivery_range | GetSetCourierDeliveryRangeRequest | GetSetCourierDeliveryRangeResponse | контракт+маппинг | | -| orders | Управлениезаказами.json | POST | /order-management/1/order/setTrackingNumber | Передача трек-номера | нет | | | | orders | Order | update_set_order_tracking_number | UpdateSetOrderTrackingNumberRequest | UpdateSetOrderTrackingNumberResponse | контракт+маппинг | | -| orders | Управлениезаказами.json | GET | /order-management/1/orders | Получение информации о заказах | нет | | | | orders | Order | get_orders | NoRequest | GetOrdersResponse | контракт+маппинг | | -| orders | Управлениезаказами.json | POST | /order-management/1/orders/labels | Создать задачу на генерацию этикеток (до 100). | нет | | | | orders | OrderLabel | create_generate_labels | CreateGenerateLabelsRequest | CreateGenerateLabelsResponse | контракт+маппинг | | -| orders | Управлениезаказами.json | POST | /order-management/1/orders/labels/extended | Создать задачу на генерацию этикеток (до 1000). | нет | | | | orders | OrderLabel | create_generate_labels_extended | CreateGenerateLabelsExtendedRequest | CreateGenerateLabelsExtendedResponse | контракт+маппинг | | -| orders | Управлениезаказами.json | GET | /order-management/1/orders/labels/{taskID}/download | Скачать сгенерированный PDF-файл (этикетку). | нет | | | | orders | OrderLabel | get_download_label | NoRequest | BinaryPdfResponse | контракт+бинарный | | -| orders | Управлениеостатками.json | POST | /stock-management/1/info | Получение остатков | нет | | | | orders | Stock | get_получение_остатков | GetПолучениеОстатковRequest | GetПолучениеОстатковResponse | контракт+маппинг | в swagger отсутствует operationId | -| orders | Управлениеостатками.json | PUT | /stock-management/1/stocks | Редактирование остатков | нет | | | | orders | Stock | update_редактирование_остатков | UpdateРедактированиеОстатковRequest | UpdateРедактированиеОстатковResponse | контракт+маппинг | в swagger отсутствует operationId | - diff --git a/docs/site/assets/_gen_reference.py b/docs/site/assets/_gen_reference.py index 97c910d..81693f6 100644 --- a/docs/site/assets/_gen_reference.py +++ b/docs/site/assets/_gen_reference.py @@ -4,28 +4,31 @@ import inspect from enum import Enum from pathlib import Path +from urllib.parse import quote import mkdocs_gen_files -from scripts.parse_inventory import InventoryRow, parse_inventory -from scripts.public_sdk_surface import public_method_name +from avito.core.domain import 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 +from avito.core.swagger_report import build_swagger_binding_report EXCLUDED_PACKAGES = {"auth", "core", "testing"} +PACKAGE_ROOT = Path("avito") +GITHUB_API_URL = "https://github.com/p141592/avito_python_api/blob/main/docs/avito/api" -def public_domain_packages(rows: list[InventoryRow]) -> list[str]: +def public_domain_packages() -> list[str]: return sorted( - { - row.sdk_package - for row in rows - if row.sdk_package and row.sdk_package not in EXCLUDED_PACKAGES - } + path.parent.name + for path in PACKAGE_ROOT.glob("*/domain.py") + if path.parent.name not in EXCLUDED_PACKAGES ) -def package_title(package: str, rows: list[InventoryRow]) -> str: - documents = sorted({row.document for row in rows if row.sdk_package == package}) - return ", ".join(documents) if documents else package +def package_title(package: str) -> str: + return package def public_enums(package: str) -> list[type[Enum]]: @@ -39,15 +42,41 @@ def public_enums(package: str) -> list[type[Enum]]: return enums -def write_domain_pages(rows: list[InventoryRow]) -> list[str]: +def public_domain_classes(package: str) -> list[type[DomainObject]]: + module = importlib.import_module(f"avito.{package}") + names = getattr(module, "__all__", ()) + 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) + return classes + + +def public_domain_methods(domain_class: type[DomainObject]) -> list[str]: + methods: list[str] = [] + for name, value in inspect.getmembers(domain_class, predicate=inspect.isfunction): + if name.startswith("_"): + continue + if value.__qualname__.startswith(f"{domain_class.__name__}."): + methods.append(name) + return methods + + +def write_domain_pages(packages: list[str]) -> list[str]: pages: list[str] = [] - for package in public_domain_packages(rows): + for package in packages: page = f"reference/domains/{package}.md" pages.append(page) enums = public_enums(package) with mkdocs_gen_files.open(page, "w") as file: file.write(f"# {package}\n\n") - file.write(f"Источник API: {package_title(package, rows)}.\n\n") + file.write(f"Публичный доменный пакет SDK: `{package_title(package)}`.\n\n") if enums: file.write("## Enum\n\n") for enum_class in enums: @@ -58,30 +87,193 @@ def write_domain_pages(rows: list[InventoryRow]) -> list[str]: return pages -def write_operations(rows: list[InventoryRow]) -> None: +def write_operations(report: dict[str, object]) -> None: + operations = report["operations"] + if not isinstance(operations, list): + raise TypeError("Swagger binding report operations must be a list.") + with mkdocs_gen_files.open("reference/operations.md", "w") as file: - file.write("# Операции API\n\n") + file.write("# Методы API\n\n") file.write( - "Таблица строится из `docs/avito/inventory.md` и связывает HTTP-операции " - "с публичными методами SDK.\n\n" + "Страница строится из Swagger operation bindings и связывает каждую " + "upstream-операцию с публичным SDK-методом. Подробные сигнатуры, модели " + "и docstring-контракты находятся на страницах доменных пакетов.\n\n" + ) + file.write("| Spec | HTTP | Path | SDK method | Deprecated |\n") + file.write("|---|---|---|---|---|\n") + for operation in operations: + if not isinstance(operation, dict): + raise TypeError("Swagger binding report operation entry must be an object.") + binding = operation["binding"] + sdk_method = "" + if isinstance(binding, dict): + sdk_method = str(binding["sdk_method"]) + file.write( + f"| `{operation['spec']}` | `{operation['method']}` | " + f"`{operation['path']}` | `{sdk_method}` | " + f"{'yes' if operation['deprecated'] else 'no'} |\n" + ) + + +def write_coverage(report: dict[str, object]) -> None: + summary = report["summary"] + operations = report["operations"] + if not isinstance(summary, dict): + raise TypeError("Swagger binding report summary must be an object.") + if not isinstance(operations, list): + raise TypeError("Swagger binding report operations must be a list.") + + specs: dict[str, dict[str, int]] = {} + for operation in operations: + if not isinstance(operation, dict): + raise TypeError("Swagger binding report operation entry must be an object.") + spec = str(operation["spec"]) + spec_summary = specs.setdefault(spec, {"total": 0, "bound": 0, "deprecated": 0}) + spec_summary["total"] += 1 + if operation["status"] == "bound": + spec_summary["bound"] += 1 + if operation["deprecated"]: + spec_summary["deprecated"] += 1 + + with mkdocs_gen_files.open("reference/coverage.md", "w") as file: + file.write("# Покрытие API\n\n") + file.write( + "Swagger/OpenAPI-спецификации в `docs/avito/api/` остаются источником " + "истины, а карта покрытия SDK строится из Swagger operation bindings " + "на публичных SDK-методах.\n\n" ) file.write( - "| Описание | HTTP | SDK | Тип ответа | Deprecated |\n" - "|---|---|---|---|---|\n" + f"SDK покрывает {summary['bound']} из {summary['operations_total']} " + f"операций Avito API. Deprecated operations: " + f"{summary['deprecated_operations']}.\n\n" ) - for row in rows: - method_name = public_method_name(row) - sdk = f"`avito.{row.sdk_package}.{row.domain_object}.{method_name}()`" - http = f"`{row.method} {row.path}`" - deprecated = "нет" - if row.deprecated: - deprecated = "да" - if row.replacement: - deprecated += f"; замена `{row.replacement}`" + file.write("!!! info \"Источник данных\"\n") + file.write( + " Страница генерируется из JSON-compatible Swagger binding report, " + "который строится из локальных specs и binding discovery.\n\n" + ) + file.write("| Документ API | Операции | Bound | Deprecated | Swagger/OpenAPI |\n") + file.write("|---|---:|---:|---:|---|\n") + for spec, spec_summary in sorted(specs.items()): + quoted_spec = quote(spec) file.write( - f"| {row.description} | {http}
`{row.document}` | " - f"{sdk} | `{row.response_type}` | {deprecated} |\n" + f"| `{spec}` | {spec_summary['total']} | {spec_summary['bound']} | " + f"{spec_summary['deprecated']} | " + f"[{spec}]({GITHUB_API_URL}/{quoted_spec}) |\n" ) + file.write("\nПубличная карта операций: [Методы API](operations.md).\n") + + +def write_api_report(report: dict[str, object]) -> None: + summary = report["summary"] + operations = report["operations"] + bindings = report["bindings"] + errors = report["errors"] + if not isinstance(summary, dict): + raise TypeError("Swagger binding report summary must be an object.") + if not isinstance(operations, list): + raise TypeError("Swagger binding report operations must be a list.") + if not isinstance(bindings, list): + raise TypeError("Swagger binding report bindings must be a list.") + if not isinstance(errors, list): + raise TypeError("Swagger binding report errors must be a list.") + + specs: dict[str, dict[str, int]] = {} + deprecated_operations: list[dict[str, object]] = [] + for operation in operations: + if not isinstance(operation, dict): + raise TypeError("Swagger binding report operation entry must be an object.") + spec = str(operation["spec"]) + spec_summary = specs.setdefault( + spec, + {"total": 0, "bound": 0, "unbound": 0, "duplicate": 0, "deprecated": 0}, + ) + spec_summary["total"] += 1 + status = str(operation["status"]) + if status in {"bound", "unbound", "duplicate"}: + spec_summary[status] += 1 + if operation["deprecated"]: + spec_summary["deprecated"] += 1 + deprecated_operations.append(operation) + + operations_total = int(summary["operations_total"]) + bound = int(summary["bound"]) + coverage_percent = 100.0 if operations_total == 0 else bound / operations_total * 100 + strict_passed = ( + bound == operations_total + and int(summary["unbound"]) == 0 + and int(summary["duplicate"]) == 0 + and int(summary["ambiguous"]) == 0 + and not errors + ) + + with mkdocs_gen_files.open("reference/api-report.md", "w") as file: + file.write("# Отчёт покрытия API\n\n") + file.write( + "Страница строится при сборке документации из strict Swagger binding " + "report. Она показывает полноту связи между upstream Swagger operations " + "и публичными SDK methods.\n\n" + ) + file.write("## Summary\n\n") + file.write("| Метрика | Значение |\n") + file.write("|---|---:|\n") + file.write(f"| Swagger specs | {summary['specs']} |\n") + file.write(f"| Operations total | {operations_total} |\n") + file.write(f"| Bound operations | {bound} |\n") + file.write(f"| Unbound operations | {summary['unbound']} |\n") + file.write(f"| Duplicate operation bindings | {summary['duplicate']} |\n") + file.write(f"| Ambiguous bindings | {summary['ambiguous']} |\n") + file.write(f"| Deprecated operations | {summary['deprecated_operations']} |\n") + file.write(f"| Validation errors | {len(errors)} |\n") + file.write(f"| Coverage | {coverage_percent:.1f}% |\n") + file.write(f"| Strict gate | {'passed' if strict_passed else 'failed'} |\n\n") + + file.write("## Локальная проверка\n\n") + file.write("```bash\n") + file.write("make swagger-coverage\n") + file.write("poetry run python scripts/download_avito_api_specs.py --clean\n") + file.write("poetry run python scripts/lint_swagger_bindings.py --json --strict\n") + file.write("```\n\n") + + file.write("## Coverage By Spec\n\n") + file.write("| Документ API | Operations | Bound | Unbound | Duplicate | Deprecated |\n") + file.write("|---|---:|---:|---:|---:|---:|\n") + for spec, spec_summary in sorted(specs.items()): + file.write( + f"| `{spec}` | {spec_summary['total']} | {spec_summary['bound']} | " + f"{spec_summary['unbound']} | {spec_summary['duplicate']} | " + f"{spec_summary['deprecated']} |\n" + ) + + file.write("\n## Deprecated Operations\n\n") + if deprecated_operations: + file.write("| Spec | HTTP | Path | SDK method |\n") + file.write("|---|---|---|---|\n") + for operation in deprecated_operations: + binding = operation["binding"] + sdk_method = "" + if isinstance(binding, dict): + sdk_method = str(binding["sdk_method"]) + file.write( + f"| `{operation['spec']}` | `{operation['method']}` | " + f"`{operation['path']}` | `{sdk_method}` |\n" + ) + else: + file.write("Deprecated operations не найдены.\n") + + file.write("\n## Validation Errors\n\n") + if errors: + file.write("| Code | Operation | SDK method | Message |\n") + file.write("|---|---|---|---|\n") + for error in errors: + if not isinstance(error, dict): + raise TypeError("Swagger binding report error entry must be an object.") + file.write( + f"| `{error['code']}` | `{error['operation_key']}` | " + f"`{error['sdk_method']}` | {error['message']} |\n" + ) + else: + file.write("Ошибок strict validation нет.\n") def write_enums(packages: list[str]) -> None: @@ -102,6 +294,7 @@ def write_summary(domain_pages: list[str]) -> None: with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as file: file.write("* [Reference](index.md)\n") file.write("* [Покрытие API](coverage.md)\n") + file.write("* [Отчёт покрытия API](api-report.md)\n") file.write("* [AvitoClient](client.md)\n") file.write("* [Конфигурация](config.md)\n") file.write("* [Операции API](operations.md)\n") @@ -126,10 +319,18 @@ def ensure_debug_info_exists() -> None: def main() -> None: ensure_debug_info_exists() - rows = parse_inventory() - packages = public_domain_packages(rows) - domain_pages = write_domain_pages(rows) - write_operations(rows) + registry = load_swagger_registry() + discovery = discover_swagger_bindings(registry=registry) + lint_errors = lint_swagger_bindings(registry, discovery, strict=True) + if registry.errors or lint_errors: + raise RuntimeError("Swagger binding report contains validation errors.") + report = build_swagger_binding_report(registry, discovery).to_dict() + + packages = public_domain_packages() + domain_pages = write_domain_pages(packages) + write_coverage(report) + write_api_report(report) + write_operations(report) write_enums(packages) write_summary(domain_pages) diff --git a/docs/site/explanations/.pages b/docs/site/explanations/.pages index ff075d8..7e3f9b8 100644 --- a/docs/site/explanations/.pages +++ b/docs/site/explanations/.pages @@ -8,5 +8,6 @@ nav: - dry-run-and-idempotency.md - testing-strategy.md - api-coverage-and-deprecations.md + - swagger-binding-subsystem.md - config-resolution.md - security-and-redaction.md diff --git a/docs/site/explanations/api-coverage-and-deprecations.md b/docs/site/explanations/api-coverage-and-deprecations.md index 6c82b3f..1ed9c8c 100644 --- a/docs/site/explanations/api-coverage-and-deprecations.md +++ b/docs/site/explanations/api-coverage-and-deprecations.md @@ -1,28 +1,36 @@ # Покрытие API и deprecation -Swagger/OpenAPI-файлы в `docs/avito/api/` считаются upstream source of truth. `docs/avito/inventory.md` связывает каждую HTTP-операцию с доменным объектом SDK, публичным методом, типами запроса/ответа и deprecation metadata. +Swagger/OpenAPI-файлы в `docs/avito/api/` считаются upstream source of truth. Строгие проверки сначала обновляют этот каталог из публичного Avito developer portal, затем строят Swagger binding report через binding discovery на публичных SDK-методах. Справочник reference строится из публичной поверхности SDK, а страницы покрытия и карты операций генерируются из этого report. ```mermaid flowchart LR - spec[docs/avito/api/*.json] --> sync[check_spec_inventory_sync.py] - inventory[docs/avito/inventory.md] --> sync - inventory --> coverage[check_inventory_coverage.py] - inventory --> reference[Generated reference] - sdk[avito/* public API] --> coverage + spec[docs/avito/api/*.json] --> bindings[Swagger operation bindings] + bindings --> sdk[avito/* public API] + bindings --> report[Swagger binding report] + report --> reference[Generated reference] + sdk --> warnings[Runtime warnings] ``` ## Почему нужны оба источника -OpenAPI описывает upstream API. Inventory описывает, где эта операция живёт в SDK. Если операция есть в spec, но отсутствует в inventory, пользователь не найдёт её в SDK. Если операция есть в inventory, но отсутствует в spec, inventory устарел или описывает неподтверждённый контракт. +OpenAPI описывает upstream API. Reference описывает публичный SDK-контракт, с которым работает пользователь. Если операция есть в spec, но отсутствует в публичной поверхности SDK, пользователь не найдёт её в документации и не сможет вызвать через фасад. ## Deprecated metadata -Для deprecated-операций inventory хранит `deprecated_since`, `replacement` и `removal_version`. Эти поля нужны сразу в трёх местах: runtime `DeprecationWarning`, reference warning и changelog/release notes. +Для deprecated-операций SDK хранит `deprecated_since`, `replacement` и `removal_version`. Эти поля нужны сразу в трёх местах: runtime `DeprecationWarning`, reference warning и changelog/release notes. Deprecated-страница в reference не заменяет runtime warning. Если символ устарел, пользователь должен получить предупреждение при вызове, а не только при чтении сайта. +## Legacy policy + +Operation-level `deprecated: true` в Swagger означает, что публичный SDK binding обязан иметь `deprecated=True` и `legacy=True`. Такой binding разрешён только для операции, которая действительно помечена deprecated в Swagger. + +Для deprecated binding публичный метод SDK должен быть обёрнут через `deprecated_method(...)`, чтобы при вызове был runtime `DeprecationWarning` с `deprecated_since`, `replacement` и `removal_version`. `legacy=True` для non-deprecated операции запрещён без отдельного allowlist-исключения с причиной и датой удаления. + ## Гейты -`check_spec_inventory_sync.py` сверяет operation-level coverage: документ, раздел, HTTP method и path. `check_inventory_coverage.py` сверяет связь inventory с публичной SDK-поверхностью и sanity deprecation-полей. +Публичная поверхность проверяется contract-тестами и сборкой reference-документации. `make swagger-coverage` скачивает свежие Swagger files, запускает strict binding validation и полный contract suite. Deprecated-символы должны сохранять runtime warning, а не только пометку в документации. + +Страница для пользователя: [покрытие API](../reference/coverage.md). Детальный отчёт: [отчёт покрытия API](../reference/api-report.md). Карта операций: [operations reference](../reference/operations.md). -Страница для пользователя: [покрытие API](../reference/coverage.md). Карта операций: [operations reference](../reference/operations.md). +Подробная механика discovery, strict lint, JSON report и `SwaggerFakeTransport` описана в [Swagger binding subsystem](swagger-binding-subsystem.md). diff --git a/docs/site/explanations/index.md b/docs/site/explanations/index.md index c4617f9..8c8bacd 100644 --- a/docs/site/explanations/index.md +++ b/docs/site/explanations/index.md @@ -11,7 +11,8 @@ Explanations описывают причины архитектурных реш | [Семантика пагинации](pagination-semantics.md) | Почему `PaginatedList` ленивый и когда загружаются страницы | | [Dry-run и идемпотентность](dry-run-and-idempotency.md) | Как write-операции проверяются без сетевого вызова | | [Стратегия тестирования](testing-strategy.md) | Как `FakeTransport`, contract-тесты и docs-harness проверяют SDK | -| [Покрытие API и deprecation](api-coverage-and-deprecations.md) | Как spec, inventory, reference и runtime warnings связаны между собой | +| [Покрытие API и deprecation](api-coverage-and-deprecations.md) | Как specs, reference и runtime warnings связаны между собой | +| [Swagger binding subsystem](swagger-binding-subsystem.md) | Как Swagger specs, bindings, strict lint, JSON report и contract runner сохраняют coverage-контекст | | [Resolution конфигурации](config-resolution.md) | Как env, `.env` и defaults превращаются в `AvitoSettings` | | [Security и redaction](security-and-redaction.md) | Какие секреты SDK не раскрывает в диагностике и ошибках | diff --git a/docs/site/explanations/swagger-binding-subsystem.md b/docs/site/explanations/swagger-binding-subsystem.md new file mode 100644 index 0000000..7eb556f --- /dev/null +++ b/docs/site/explanations/swagger-binding-subsystem.md @@ -0,0 +1,214 @@ +# Swagger binding subsystem + +Swagger binding subsystem связывает локальный OpenAPI corpus с публичной поверхностью SDK. Его задача — доказуемо ответить на два вопроса: + +- какая upstream Swagger operation покрыта каким публичным SDK-методом; +- как contract-test runner должен вызвать этот SDK-метод без реального HTTP. + +Swagger/OpenAPI-файлы в `docs/avito/api/*.json` остаются единственным источником истины по HTTP-контракту: method, path, parameters, request body, content type, statuses, schemas и operation-level `deprecated`. Binding-и не дублируют эти данные. Они хранят только адресацию между SDK и Swagger. + +## Основные компоненты + +| Компонент | Файл | Ответственность | +|---|---|---| +| Swagger downloader | `scripts/download_avito_api_specs.py` | Скачивает свежий upstream OpenAPI catalog в `docs/avito/api/` и удаляет stale specs | +| Binding decorator | `avito/core/swagger.py` | Записывает metadata на публичный SDK-метод | +| Swagger registry | `avito/core/swagger_registry.py` | Загружает `docs/avito/api/*.json`, нормализует операции и проверяет базовую валидность specs | +| Binding discovery | `avito/core/swagger_discovery.py` | Находит decorated public domain methods без создания `AvitoClient` и без HTTP | +| Linter | `avito/core/swagger_linter.py`, `scripts/lint_swagger_bindings.py` | Проверяет, что binding-и полные, уникальные и соответствуют Swagger | +| Report | `avito/core/swagger_report.py` | Формирует JSON report для docs/reference и coverage | +| Factory map | `avito/core/swagger_factory_map.py` | Даёт вспомогательную, неканоническую карту `AvitoClient factory -> domain class -> spec candidates` | +| Contract runner | `avito/testing/swagger_fake_transport.py` | Строит SDK-вызовы по binding metadata и валидирует фактический request/response через Swagger | + +Каноническая карта покрытия строится только из `Swagger operation key -> discovered binding`. Markdown inventory не участвует в coverage и не является источником истины. + +## Binding metadata + +Публичный декоратор: + +```python +@swagger_operation( + method: str, + path: str, + *, + spec: str | None = None, + operation_id: str | None = None, + factory: str | None = None, + factory_args: Mapping[str, str] | None = None, + method_args: Mapping[str, str] | None = None, + deprecated: bool = False, + legacy: bool = False, +) +``` + +Class-level metadata на domain object задаёт defaults: + +```python +__swagger_domain__: str +__swagger_spec__: str +__sdk_factory__: str +__sdk_factory_args__: Mapping[str, str] +``` + +Приоритет значений: + +1. Значения из `@swagger_operation(...)`. +2. Значения из class-level metadata. +3. Auto-resolve через registry, только если `method + normalized_path` совпадает ровно с одной Swagger operation во всём corpus. + +Decorator записывает metadata в `func.__swagger_binding__`. Он не меняет поведение метода и не читает Swagger-файлы на import time. Повторная разметка того же SDK method запрещена, а legacy metadata `__swagger_bindings__` считается ошибкой совместимости. + +## Operation identity + +Primary key операции: + +```text +spec + method + normalized_path +``` + +Нормализация: + +- `method` приводится к uppercase; +- trailing slash удаляется, кроме `/`; +- path хранится в Swagger format: `/path/{param}`; +- path parameter aliases, отличающиеся только стилем записи (`userId`/`user_id`), нормализуются к имени описанного Swagger parameter; +- path остаётся case-sensitive; +- syntax path parameter кроме `{name}` запрещён. + +`operation_id` является дополнительной проверкой. Он помогает поймать ошибочный binding, но не является primary identity. + +## Expression mappings + +`factory_args` и `method_args` описывают, как generated contract data превращается в вызов публичного SDK: + +| Expression | Источник | +|---|---| +| `path.` | path parameter Swagger operation | +| `query.` | query parameter Swagger operation | +| `header.` | header parameter Swagger operation | +| `body` | весь request body | +| `body.` | поле request body | +| `constant.` | контролируемая тестовая константа | + +Expressions не являются Python-кодом. Произвольные callables, dotted paths вне whitelist и transport/request DTO запрещены. + +Текущая реализация валидирует `path.*`, `query.*`, `header.*`, наличие `requestBody` для `body`, field-level `body.` против top-level request body schema properties и наличие `constant.*` в test constants registry. Для Swagger properties с camelCase/Pascal acronym naming registry также хранит SDK-style snake_case aliases, чтобы binding мог ссылаться на публичные Python-имена без потери schema-aware проверки. + +## Discovery + +Discovery импортирует пакет `avito`, но не создаёт `AvitoClient`, не читает обязательные env vars и не делает сетевых вызовов. Сканируются публичные domain classes из `avito//domain.py` и заранее описанные non-domain exceptions, например low-level auth token bindings. + +Игнорируются: + +- private methods; +- internal helpers; +- summary/helper methods на `AvitoClient`, если они не соответствуют одной конкретной upstream operation; +- section clients как canonical target, кроме явно задокументированных legacy/non-domain exceptions. + +## Linter modes + +Основные команды: + +```bash +poetry run python scripts/lint_swagger_bindings.py +poetry run python scripts/lint_swagger_bindings.py --strict +poetry run python scripts/lint_swagger_bindings.py --json --strict --output swagger-bindings-report.json +make swagger-update +make swagger-lint +make swagger-coverage +``` + +Non-strict mode валидирует specs и уже найденные bindings. Strict mode дополнительно требует, чтобы каждая Swagger operation имела ровно один binding. `make swagger-lint` сначала скачивает свежие Swagger/OpenAPI files через `make swagger-update`, затем запускает strict validation. `make swagger-coverage` дополнительно запускает полный Swagger contract suite и входит в `make check`. + +JSON report используется как стабильный machine-readable API для generated reference и coverage: + +```json +{ + "summary": { + "specs": 23, + "operations_total": 204, + "deprecated_operations": 7, + "bound": 204, + "unbound": 0, + "duplicate": 0, + "ambiguous": 0 + }, + "operations": [], + "bindings": [], + "factory_mapping": {}, + "errors": [] +} +``` + +## Deprecated and legacy policy + +Operation-level `deprecated: true` in Swagger requires: + +- `deprecated=True` on binding; +- `legacy=True` on binding; +- runtime `DeprecationWarning` on the public SDK method through `deprecated_method(...)`. + +`legacy=True` on a non-deprecated operation is forbidden unless a separate allowlist entry exists with a reason and removal date. Deprecated schema fields, properties and enum values do not create operation-level legacy requirements. + +## Multi-operation SDK methods + +The strict invariant is: + +```text +each Swagger operation -> exactly one discovered binding +each discovered SDK method -> exactly one Swagger operation +``` + +One SDK method must not have multiple Swagger bindings. When a user-facing scenario has several upstream modes, the canonical bindings belong to separate documented SDK methods; compatibility wrappers may delegate to those methods but must not carry additional bindings. + +## Contract tests + +`SwaggerFakeTransport` uses discovered binding metadata to: + +1. Build an `AvitoClient` with fake transport. +2. Create the correct domain object through `AvitoClient` factory and `factory_args`. +3. Call the public SDK method with `method_args`. +4. Match the actual HTTP request against Swagger method/path. +5. Validate required path/query/header parameters and request body/content type. +6. Return declared Swagger response statuses only. +7. Let normal SDK mapping and exception mapping run. + +Contract tests must stay network-free. They are not a replacement for domain tests, but they catch binding drift: a method can be present in docs yet still fail contract invocation if factory args, method args, path, body or status handling are wrong. + +The contract suite is exhaustive over the Swagger binding map: + +- one request-contract case per discovered binding; +- one error-contract case per numeric Swagger error response; +- deprecated operation bindings are included in the request set and additionally checked for runtime `DeprecationWarning`. + +`SwaggerFakeTransport` provides deterministic generated SDK arguments and success payloads. The default success payload is the minimal JSON object accepted by most SDK mappers; operations whose mappers require a domain-specific response shape are listed in the controlled payload registry in `avito/testing/swagger_fake_transport.py`. Missing generated arguments or unsupported payload shapes are contract failures, not allowlisted gaps. + +## API method change checklist + +When adding or changing a public API method that corresponds to Avito API: + +1. Confirm the upstream operation in `docs/avito/api/*.json`. +2. Add or update the domain method, section client call, mapper and public models. +3. Add `@swagger_operation(...)` on the public domain method without schemas/statuses/content types in the decorator. +4. Add or update class-level metadata if the domain class is new. +5. Document the public method through docstring so generated reference explains arguments, return model, pagination/dry-run/idempotency behavior and common exceptions. +6. Add focused domain tests with `FakeTransport`. +7. Add or adjust mapper/model tests when response or serialization changes. +8. Ensure the binding is exercised by strict `make swagger-lint` and the exhaustive `SwaggerFakeTransport` contract tests. +9. Update user-facing docs when the method creates a new workflow, changes behavior, or introduces a non-obvious contract. + +Minimum local verification for API-surface changes: + +```bash +make swagger-lint +poetry run pytest tests/core/test_swagger*.py tests/contracts/test_swagger_contracts.py +poetry run pytest tests/domains// +poetry run mypy avito +poetry run ruff check . +``` + +Before merging a complete API change, run: + +```bash +make check +``` diff --git a/docs/site/explanations/testing-strategy.md b/docs/site/explanations/testing-strategy.md index b89ddd8..99f761c 100644 --- a/docs/site/explanations/testing-strategy.md +++ b/docs/site/explanations/testing-strategy.md @@ -7,10 +7,10 @@ SDK тестируется через публичные контракты: д | Уровень | Что проверяет | |---|---| | Unit | Мапперы, сериализация моделей, validation | -| Contract | Публичная поверхность, исключения, deprecated warnings | +| Contract | Публичная поверхность, все Swagger bindings, все numeric Swagger error responses, deprecated warnings | | Domain | Доменные методы поверх `FakeTransport` | | Docs | README/tutorials/how-to snippets через `mktestdocs` | -| Build gates | Inventory/spec sync, reference surface, docstring contract | +| Build gates | Swagger binding discovery, reference surface, docstring contract | ## FakeTransport @@ -18,6 +18,10 @@ SDK тестируется через публичные контракты: д Docs-harness использует тот же подход: `AvitoClient.from_env()` в markdown-примерах возвращает клиент поверх fake transport, поэтому copy-paste snippets проходят в CI без сетевых запросов. +## Swagger contract coverage + +`SwaggerFakeTransport` строит вызовы по discovered `@swagger_operation` metadata и проверяет фактический HTTP-запрос against локальный Swagger corpus. Contract suite содержит один request case на каждый discovered binding и один error case на каждый numeric Swagger error response. Если generated call, required parameter, content type, status или exception mapping расходятся со Swagger, тест падает без allowlist. + ## Почему не мокать domain methods Если тест подменяет `account().get_self()` напрямую, он проверяет только consumer-код. Если тест строит `AvitoClient` поверх fake transport, он дополнительно проверяет HTTP path, payload, mapper и публичную модель. Поэтому fake transport ближе к реальному интеграционному контракту. diff --git a/docs/site/explanations/transport-and-retries.md b/docs/site/explanations/transport-and-retries.md index 0bc4e70..05c409c 100644 --- a/docs/site/explanations/transport-and-retries.md +++ b/docs/site/explanations/transport-and-retries.md @@ -25,7 +25,9 @@ flowchart TD Retry применяется только там, где операция помечена как безопасная для повтора. Read/list/probe операции обычно допускают retry. Write-операции получают retry только при явной идемпотентности, например через `idempotency_key`, или когда конкретный section client помечает операцию как безопасную. -`429` учитывает `Retry-After`, если upstream его вернул. Для `5xx` используется retry-политика transport-слоя. Ошибки маппинга не повторяются: если JSON уже получен, но не соответствует контракту модели, это `ResponseMappingError`, а не сетевой сбой. +`429` учитывает `Retry-After`, если upstream его вернул. Если `Retry-After` отсутствует, transport использует обычный exponential backoff с jitter. Для `5xx` используется retry-политика transport-слоя. Ошибки маппинга не повторяются: если JSON уже получен, но не соответствует контракту модели, это `ResponseMappingError`, а не сетевой сбой. + +Чтобы снижать вероятность `429` до ответа upstream, можно включить локальный token bucket через `AVITO_RATE_LIMIT_ENABLED=true`. Лимитер применяется в transport-слое перед отправкой запроса и дополнительно учитывает `X-RateLimit-Remaining: 0`, когда API возвращает этот заголовок. ## Почему retry не в доменах diff --git a/docs/site/reference/.pages b/docs/site/reference/.pages index 3c4c119..29edad8 100644 --- a/docs/site/reference/.pages +++ b/docs/site/reference/.pages @@ -1,6 +1,7 @@ nav: - index.md - coverage.md + - api-report.md - client.md - config.md - operations.md diff --git a/docs/site/reference/config.md b/docs/site/reference/config.md index a6a4d5e..f037513 100644 --- a/docs/site/reference/config.md +++ b/docs/site/reference/config.md @@ -79,6 +79,9 @@ OAuth-credentials и полный объект настроек. Приорит | `AVITO_RETRY_RETRY_ON_SERVER_ERROR` | `true` | Повторять запрос при ответах `5xx`. | | `AVITO_RETRY_RETRY_ON_TRANSPORT_ERROR` | `true` | Повторять запрос при сетевых ошибках (обрыв соединения, DNS). | | `AVITO_RETRY_MAX_RATE_LIMIT_WAIT_SECONDS` | `30.0` | Максимальное время ожидания при `429`, если сервер вернул `Retry-After`. | +| `AVITO_RATE_LIMIT_ENABLED` | `false` | Включить локальное превентивное ограничение частоты запросов перед отправкой в API. | +| `AVITO_RATE_LIMIT_REQUESTS_PER_SECOND` | `8.0` | Целевая частота запросов для локального token bucket. | +| `AVITO_RATE_LIMIT_BURST` | `8` | Максимальный краткий burst перед принудительной паузой. | ## Per-operation overrides diff --git a/docs/site/reference/coverage.md b/docs/site/reference/coverage.md index 17dc3fa..4eacabc 100644 --- a/docs/site/reference/coverage.md +++ b/docs/site/reference/coverage.md @@ -1,34 +1,15 @@ # Покрытие API -SDK покрывает 204 операции Avito API. Swagger/OpenAPI-спецификации в `docs/avito/api/` остаются источником истины, а inventory фиксирует соответствие операций публичным методам SDK. +Эта страница генерируется при сборке MkDocs из Swagger binding report. +Исходный код генератора: `docs/site/assets/_gen_reference.py`. -!!! info "Источник данных" - Эта страница не ссылается на файлы вне `docs_dir` относительными путями, чтобы `mkdocs build --strict` оставался зелёным. Ссылки ниже ведут на файлы спецификаций в GitHub. +Swagger/OpenAPI-спецификации в `docs/avito/api/` остаются источником истины, +а карта покрытия SDK строится из Swagger operation bindings на публичных +SDK-методах. Локальная проверка: -| Документ API | Swagger/OpenAPI | -|---|---| -| CPA-аукцион | [CPA-аукцион.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/CPA-аукцион.json) | -| CPA Авито | [CPAАвито.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/CPAАвито.json) | -| Call Tracking | [CallTracking[КТ].json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/CallTracking%5BКТ%5D.json) | -| TrxPromo | [TrxPromo.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/TrxPromo.json) | -| Авито Работа | [АвитоРабота.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/АвитоРабота.json) | -| Автозагрузка | [Автозагрузка.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/Автозагрузка.json) | -| Автостратегия | [Автостратегия.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/Автостратегия.json) | -| Автотека | [Автотека.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/Автотека.json) | -| Авторизация | [Авторизация.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/Авторизация.json) | -| Аналитика по недвижимости | [Аналитикапонедвижимости.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/Аналитикапонедвижимости.json) | -| Доставка | [Доставка.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/Доставка.json) | -| Иерархия аккаунтов | [ИерархияАккаунтов.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/ИерархияАккаунтов.json) | -| Информация о пользователе | [Информацияопользователе.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/Информацияопользователе.json) | -| Краткосрочная аренда | [Краткосрочнаяаренда.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/Краткосрочнаяаренда.json) | -| Мессенджер | [Мессенджер.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/Мессенджер.json) | -| Настройка цены целевого действия | [Настройкаценыцелевогодействия.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/Настройкаценыцелевогодействия.json) | -| Объявления | [Объявления.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/Объявления.json) | -| Продвижение | [Продвижение.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/Продвижение.json) | -| Рассылка скидок и спецпредложений в мессенджере | [Рассылкаскидокиспецпредложенийвмессенджере.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/Рассылкаскидокиспецпредложенийвмессенджере.json) | -| Рейтинги и отзывы | [Рейтингииотзывы.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/Рейтингииотзывы.json) | -| Тарифы | [Тарифы.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/Тарифы.json) | -| Управление заказами | [Управлениезаказами.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/Управлениезаказами.json) | -| Управление остатками | [Управлениеостатками.json](https://github.com/p141592/avito_python_api/blob/main/docs/avito/api/Управлениеостатками.json) | +```bash +poetry run python scripts/lint_swagger_bindings.py --json --strict --output swagger-bindings-report.json +``` -Полная карта «операция API → публичный метод SDK» хранится в [inventory.md](https://github.com/p141592/avito_python_api/blob/main/docs/avito/inventory.md). +Сгенерированная страница показывает количество операций, bound/deprecated +статусы по каждому spec и ссылку на публичную карту операций. diff --git a/docs/site/reference/index.md b/docs/site/reference/index.md index c732ca7..a860974 100644 --- a/docs/site/reference/index.md +++ b/docs/site/reference/index.md @@ -7,8 +7,8 @@ |---|---| | [AvitoClient](client.md) | Инициализация, контекстный менеджер, фабричные методы, `debug_info()` | | [Конфигурация](config.md) | `AvitoSettings`, `AuthSettings`, env-переменные, per-operation overrides | -| [Покрытие API](coverage.md) | 23 Swagger/OpenAPI-документа и карта покрытия | -| [Операции API](operations.md) | Индекс `HTTP method/path → SDK method` из inventory | +| [Покрытие API](coverage.md) | 204/204 Swagger operations из binding report | +| [Методы API](operations.md) | Карта Swagger operation → публичный SDK-метод | | Домены | Публичные объекты и модели каждого доменного пакета | | [Enum](enums.md) | Все публичные перечисления доменных пакетов | | [Модели](models.md) | Сериализация, dataclass-контракт, публичные модели | diff --git a/pyproject.toml b/pyproject.toml index 2b69b22..d2f84ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,10 @@ pydocstyle = { version = ">=6.3", extras = ["toml"] } [tool.pytest.ini_options] testpaths = ["tests"] +pythonpath = ["scripts"] +markers = [ + "live: требует доступа к сети; запускать с --live", +] [tool.coverage.run] branch = true diff --git a/scripts/build_docs_quality_report.py b/scripts/build_docs_quality_report.py deleted file mode 100644 index b1c206d..0000000 --- a/scripts/build_docs_quality_report.py +++ /dev/null @@ -1,388 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import os -import re -import tomllib -from datetime import UTC, datetime -from pathlib import Path -from typing import Any - -from parse_inventory import parse_inventory - -ROOT = Path(__file__).resolve().parents[1] -DOCS_DIR = ROOT / "docs" / "site" -DEFAULT_OUTPUT = ROOT / "docs-quality-report.json" -PLACEHOLDER_PATTERN = re.compile( - r"Раздел в разработке|placeholder|плейсхолдер|TODO|TBD|coming soon", - re.IGNORECASE, -) - -PLANNED_DOMAIN_HOWTO = { - "accounts": "account-profile.md", - "ads": "ad-listing-and-stats.md", - "autoteka": "autoteka-report.md", - "cpa": "cpa-calltracking.md", - "jobs": "job-applications.md", - "messenger": "chat-image-upload.md", - "orders": "order-labels.md", - "promotion": "promotion-dry-run.md", - "ratings": "ratings-and-tariffs.md", - "realty": "realty-booking.md", - "tariffs": "ratings-and-tariffs.md", -} - - -def read_json(path: Path) -> dict[str, Any]: - if not path.exists(): - return {} - return json.loads(path.read_text(encoding="utf-8")) - - -def read_text(path: Path) -> str: - if not path.exists(): - return "" - return path.read_text(encoding="utf-8") - - -def sdk_version() -> str: - payload = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) - return str(payload["tool"]["poetry"]["version"]) - - -def ttfc_minutes(args: argparse.Namespace) -> float | None: - if args.ttfc_minutes is not None: - return args.ttfc_minutes - env_value = os.environ.get("TTFC_MINUTES") - if env_value: - return float(env_value) - path = ROOT / "ttfc-minutes.txt" - if path.exists(): - return float(path.read_text(encoding="utf-8").strip()) - return None - - -def semver_is_valid(version: str) -> bool: - return re.fullmatch(r"0|[1-9]\d*\.(0|[1-9]\d*)\.(0|[1-9]\d*)", version) is not None - - -def markdown_files(section: str) -> list[str]: - directory = DOCS_DIR / section - if not directory.exists(): - return [] - return sorted(path.name for path in directory.glob("*.md") if path.name != "SUMMARY.md") - - -def placeholder_count() -> int: - count = 0 - for path in DOCS_DIR.rglob("*.md"): - count += len(PLACEHOLDER_PATTERN.findall(path.read_text(encoding="utf-8"))) - return count - - -def docs_examples_harness_enabled() -> bool: - makefile = (ROOT / "Makefile").read_text(encoding="utf-8") - return ( - "poetry run pytest tests/docs/" in makefile - and (ROOT / "tests" / "docs" / "test_markdown_examples.py").exists() - and (ROOT / "tests" / "docs" / "conftest.py").exists() - ) - - -def pr_template_has_public_rename_gate() -> bool: - path = ROOT / ".github" / "pull_request_template.md" - text = read_text(path) - return "Публичное переименование" in text and "DeprecationWarning" in text - - -def debug_info_contract_is_documented() -> bool: - client_reference = read_text(DOCS_DIR / "reference" / "client.md") - security_explanation = read_text(DOCS_DIR / "explanations" / "security-and-redaction.md") - client_tests = read_text(ROOT / "tests" / "contracts" / "test_client_contracts.py") - required = ("debug_info", "client_secret", "Authorization", "secret") - return ( - all(marker in client_reference + security_explanation for marker in required[:3]) - and "test_debug_info_and_context_manager_do_not_leak_secrets" in client_tests - and "secret" in client_tests - ) - - -def testing_contract_is_documented() -> bool: - reference = read_text(DOCS_DIR / "reference" / "testing.md") - explanation = read_text(DOCS_DIR / "explanations" / "testing-strategy.md") - tests = read_text(ROOT / "tests" / "contracts" / "test_testing_api.py") - text = reference + explanation - return ( - "FakeTransport" in text - and "route_sequence" in text - and "RecordedRequest" in text - and "as_client" in text - and "test_fake_transport_builds_public_client_without_real_http" in tests - ) - - -def serialization_contract_is_documented() -> bool: - reference = read_text(DOCS_DIR / "reference" / "models.md") - explanation = read_text(DOCS_DIR / "explanations" / "security-and-redaction.md") - tests = read_text(ROOT / "tests" / "contracts" / "test_model_contracts.py") - return ( - "to_dict()" in reference - and "model_dump()" in reference - and "JSON-совмест" in reference - and "to_dict()" in explanation - and "test_recursive_serialization_is_json_compatible" in tests - ) - - -def context_manager_contract_is_documented() -> bool: - reference = read_text(DOCS_DIR / "reference" / "client.md") - tutorial = read_text(DOCS_DIR / "tutorials" / "getting-started.md") - tests = read_text(ROOT / "tests" / "contracts" / "test_client_contracts.py") - return ( - "context manager" in reference - and "close()" in reference - and "ConfigurationError" in reference - and "with AvitoClient.from_env()" in tutorial - and "test_closed_client_rejects_new_domain_factories" in tests - ) - - -def deprecation_warning_contract_is_tested() -> bool: - tests = read_text(ROOT / "tests" / "contracts" / "test_deprecation_warnings.py") - changelog = read_text(ROOT / "CHANGELOG.md") - return ( - "test_deprecated_inventory_symbols_warn_once" in tests - and "DeprecationWarning" in tests - and "DeprecationWarning" in changelog - ) - - -def bandit_high_count(report: dict[str, Any]) -> int: - metrics = report.get("metrics") - if isinstance(metrics, dict): - totals = metrics.get("_totals") - if isinstance(totals, dict) and isinstance(totals.get("SEVERITY.HIGH"), int): - return int(totals["SEVERITY.HIGH"]) - results = report.get("results") - if not isinstance(results, list): - return 0 - return sum( - 1 - for item in results - if isinstance(item, dict) and item.get("issue_severity") == "HIGH" - ) - - -def public_domains() -> list[str]: - excluded = {"auth", "core", "testing"} - return sorted({row.sdk_package for row in parse_inventory() if row.sdk_package not in excluded}) - - -def existing_domain_howto_coverage() -> dict[str, str]: - existing = {path.name for path in (DOCS_DIR / "how-to").glob("*.md")} - coverage: dict[str, str] = {} - for domain, filename in PLANNED_DOMAIN_HOWTO.items(): - if domain in public_domains() and filename in existing: - coverage[domain] = filename - return coverage - - -def grade(value: float, evidence: str) -> dict[str, float | str]: - return {"grade": value, "evidence": evidence} - - -def report_value(report: dict[str, Any], key: str) -> int: - value = report.get(key) - return int(value) if isinstance(value, int) else 0 - - -def build_report(args: argparse.Namespace) -> dict[str, Any]: - inventory_report = read_json(args.inventory_report) - spec_report = read_json(args.spec_report) - reference_report = read_json(args.reference_report) - docstring_report = read_json(args.docstring_report) - changelog_report = read_json(args.changelog_report) - bandit_report = read_json(args.bandit_report) - - tutorials = markdown_files("tutorials") - how_to = markdown_files("how-to") - reference = markdown_files("reference") - explanations = markdown_files("explanations") - domain_coverage = existing_domain_howto_coverage() - placeholders = placeholder_count() - - docstring_gaps = report_value(docstring_report, "gap_count") - changelog_gaps = report_value(changelog_report, "gap_count") - reference_gaps = report_value(reference_report, "gap_count") - inventory_gaps = report_value(inventory_report, "gap_count") - spec_gaps = report_value(spec_report, "gap_count") - - public_contract_coverage = { - "AvitoClient": "client.md", - "AvitoSettings": "config.md", - "AuthSettings": "config.md", - "factory_methods": "operations.md", - "public_models": "models.md", - "typed_exceptions": "exceptions.md", - "PaginatedList": "pagination.md", - "serialization": "models.md", - "debug_info": "client.md", - } - - domains = public_domains() - domain_grade = 1.0 if len(domain_coverage) == len(domains) else 0.25 - reference_grade = 1.0 if reference_gaps == 0 and docstring_gaps == 0 else 0.5 - harness_enabled = docs_examples_harness_enabled() - example_grade = 1.0 if harness_enabled else 0.0 - explanation_target = 10 - explanation_grade = 1.0 if len(explanations) >= explanation_target else 0.25 - rename_gate_enabled = pr_template_has_public_rename_gate() - debug_info_safe = debug_info_contract_is_documented() - testing_documented = testing_contract_is_documented() - serialization_documented = serialization_contract_is_documented() - context_manager_documented = context_manager_contract_is_documented() - version = sdk_version() - semver_ok = semver_is_valid(version) and "Semantic Versioning" in read_text(ROOT / "CHANGELOG.md") - deprecation_warning_tested = deprecation_warning_contract_is_tested() - bandit_high = bandit_high_count(bandit_report) - ttfc = ttfc_minutes(args) - ttfc_ok = ttfc is not None and ttfc <= 15.0 - - return { - "generated_at": datetime.now(UTC).isoformat(), - "sdk_version": version, - "diataxis_matrix": { - "tutorials": tutorials, - "how-to": how_to, - "reference": reference - + ["operations.md", "enums.md", *[f"domains/{domain}.md" for domain in domains]], - "explanations": explanations, - }, - "domain_howto_coverage": domain_coverage, - "public_contract_coverage": public_contract_coverage, - "disabled_criteria": ["12"], - "subcriteria": { - "15.1": grade( - 1.0 if ttfc_ok else 0.5, - f"TTFC={ttfc:.2f} минут, tutorial проходит цель <=15 минут" - if ttfc_ok - else "getting-started.md существует; TTFC ещё не измерен", - ), - "15.2": grade( - domain_grade, - f"покрыто {len(domain_coverage)} из {len(domains)} публичных доменов", - ), - "15.3": grade( - reference_grade, - f"reference-public gaps={reference_gaps}; docstring gaps={docstring_gaps}", - ), - "15.4": grade( - explanation_grade, - f"explanations pages={len(explanations)} из {explanation_target}", - ), - "15.5": grade( - 1.0 if changelog_gaps == 0 else 0.5, - f"CHANGELOG подключён; changelog sections gaps={changelog_gaps}", - ), - "15.6": grade( - example_grade, - "pytest tests/docs/ включён в docs-strict" - if harness_enabled - else "docs examples harness ещё не включён", - ), - }, - "supporting_gates": { - "7.3_debug_info_safe_by_default": grade( - 1.0 if debug_info_safe else 0.5, - "debug_info документирован и покрыт тестом на отсутствие секретов" - if debug_info_safe - else "debug_info есть в client.md", - ), - "7.5_bandit_high_severity": grade( - 1.0 if bandit_report and bandit_high == 0 else 0.0, - f"bandit high severity findings={bandit_high}" - if bandit_report - else "bandit gate ещё не подключён", - ), - "16.1_fake_transport_namespace": grade(1.0, "avito.testing экспортирует FakeTransport"), - "16.2_mock_contract_documented": grade( - 1.0 if testing_documented else 0.5, - "FakeTransport/as_client/RecordedRequest задокументированы и покрыты тестом" - if testing_documented - else "reference/testing.md создан", - ), - "16.3_json_serializable_models": grade( - 1.0 if serialization_documented else 0.5, - "to_dict/model_dump документированы и покрыты JSON-serialization тестом" - if serialization_documented - else "reference/models.md создан", - ), - "16.4_context_manager_close": grade( - 1.0 if context_manager_documented else 0.5, - "context manager/close/closed-client behavior документированы и покрыты тестом" - if context_manager_documented - else "reference/client.md создан", - ), - "18.1_semver_compliant": grade( - 1.0 if semver_ok else 0.5, - f"version {version} соответствует SemVer, CHANGELOG фиксирует Semantic Versioning" - if semver_ok - else "version читается из pyproject.toml", - ), - "18.2_deprecation_period_2minor": grade( - 1.0 if inventory_gaps == 0 else 0.0, - f"inventory coverage gaps={inventory_gaps}", - ), - "18.3_deprecation_warning_emitted": grade( - 1.0 if deprecation_warning_tested else 0.75, - "deprecated inventory symbols покрыты тестом DeprecationWarning и CHANGELOG" - if deprecation_warning_tested - else "tests/contracts/test_deprecation_warnings.py покрывает inventory deprecated", - ), - "18.4_changelog_sections": grade( - 1.0 if changelog_gaps == 0 else 0.0, - f"changelog sections gaps={changelog_gaps}", - ), - "18.5_public_renames_via_alias": grade( - 1.0 if rename_gate_enabled else 0.0, - "PR template содержит gate публичного переименования" - if rename_gate_enabled - else "PR template gate ещё не добавлен", - ), - }, - "ttfc_minutes": ttfc, - "lychee_broken_links": 0, - "placeholder_count": placeholders, - "inventory_coverage_gaps": inventory_gaps, - "spec_inventory_gaps": spec_gaps, - "reference_public_gaps": reference_gaps, - "docstring_contract_gaps": docstring_gaps, - "reference_explanation_examples_gaps": 0, - "changelog_sections_gaps": changelog_gaps, - "bandit_high_severity_gaps": bandit_high, - } - - -def main() -> None: - parser = argparse.ArgumentParser(description="Собрать docs-quality-report.json.") - parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) - parser.add_argument("--inventory-report", type=Path, default=ROOT / "inventory-coverage-report.json") - parser.add_argument("--spec-report", type=Path, default=ROOT / "spec-inventory-report.json") - parser.add_argument("--reference-report", type=Path, default=ROOT / "reference-public-report.json") - parser.add_argument( - "--docstring-report", type=Path, default=ROOT / "docstring-contract-report.json" - ) - parser.add_argument( - "--changelog-report", type=Path, default=ROOT / "changelog-sections-report.json" - ) - parser.add_argument("--bandit-report", type=Path, default=ROOT / "bandit-report.json") - parser.add_argument("--ttfc-minutes", type=float, default=None) - args = parser.parse_args() - - report = build_report(args) - args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - - -if __name__ == "__main__": - main() diff --git a/scripts/check_changelog_sections.py b/scripts/check_changelog_sections.py deleted file mode 100644 index 2d791d4..0000000 --- a/scripts/check_changelog_sections.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import re -from dataclasses import asdict, dataclass -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -DEFAULT_CHANGELOG = ROOT / "CHANGELOG.md" -DEFAULT_OUTPUT = ROOT / "changelog-sections-report.json" -REQUIRED_SECTIONS = ("Added", "Changed", "Deprecated", "Removed", "Fixed") - - -@dataclass(slots=True, frozen=True) -class ChangelogGap: - version: str - section: str - reason: str - - -def current_release_block(text: str) -> tuple[str, str]: - heading = re.search(r"^## \[(?P[^\]]+)\].*$", text, re.MULTILINE) - if heading is None: - raise ValueError("В CHANGELOG.md не найден заголовок версии `## [...]`.") - next_heading = re.search(r"^## \[", text[heading.end() :], re.MULTILINE) - end = heading.end() + next_heading.start() if next_heading is not None else len(text) - return heading.group("version"), text[heading.end() : end] - - -def collect_gaps(path: Path) -> list[ChangelogGap]: - version, block = current_release_block(path.read_text(encoding="utf-8")) - sections = set(re.findall(r"^### ([A-Za-z]+)\s*$", block, re.MULTILINE)) - return [ - ChangelogGap(version, section, "секция отсутствует в текущем релизном блоке") - for section in REQUIRED_SECTIONS - if section not in sections - ] - - -def write_report(gaps: list[ChangelogGap], output: Path) -> None: - report = { - "required_sections": list(REQUIRED_SECTIONS), - "gaps": [asdict(gap) for gap in gaps], - "gap_count": len(gaps), - } - output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - - -def main() -> None: - parser = argparse.ArgumentParser(description="Проверить секции текущего CHANGELOG-блока.") - parser.add_argument("--changelog", type=Path, default=DEFAULT_CHANGELOG) - parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) - parser.add_argument("--strict", action="store_true") - args = parser.parse_args() - - gaps = collect_gaps(args.changelog) - write_report(gaps, args.output) - if args.strict and gaps: - raise SystemExit(1) - - -if __name__ == "__main__": - main() diff --git a/scripts/check_docs_examples.py b/scripts/check_docs_examples.py deleted file mode 100644 index 45e925d..0000000 --- a/scripts/check_docs_examples.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Проверяет, что python/pycon блоки в reference/ и explanations/ не являются «orphaned». - -Orphaned блок — fenced code block с меткой `python` или `pycon`, который находится в -`docs/site/reference/` или `docs/site/explanations/` и НЕ включён в mktestdocs-сборщик -(README.md, tutorials/*.md, how-to/*.md). - -По умолчанию такие блоки запрещены: если блок показывает SDK-вызов, он должен либо -исполняться через тот же harness, либо быть помечен нейтральным fence (text, console и т.д.). - -Использование: - python scripts/check_docs_examples.py [--output report.json] [--strict] -""" - -from __future__ import annotations - -import argparse -import json -import re -import sys -from pathlib import Path - -DOCS_ROOT = Path("docs/site") -CHECKED_DIRS = ["reference", "explanations"] -EXECUTABLE_DIRS = ["tutorials", "how-to"] -EXECUTABLE_FILES = ["README.md"] - -EXECUTABLE_FENCE = re.compile(r"^```(python|pycon)\s*$", re.MULTILINE) -ANY_FENCE_OPEN = re.compile(r"^```(\S*)\s*$", re.MULTILINE) - - -def collect_executable_fences(paths: list[Path]) -> set[str]: - """Собирает содержимое python/pycon блоков из executable-файлов.""" - - blocks: set[str] = set() - for path in paths: - if not path.exists(): - continue - text = path.read_text(encoding="utf-8") - for block in extract_fenced_blocks(text, {"python", "pycon"}): - blocks.add(block.strip()) - return blocks - - -def extract_fenced_blocks(text: str, fence_types: set[str]) -> list[str]: - """Извлекает содержимое fenced-блоков заданных типов.""" - - blocks: list[str] = [] - lines = text.splitlines() - in_block = False - current_lines: list[str] = [] - - for line in lines: - if not in_block: - m = re.match(r"^```(\S*)\s*$", line) - if m: - fence_type = m.group(1).lower() - if fence_type in fence_types: - in_block = True - current_lines = [] - else: - if line.strip() == "```": - blocks.append("\n".join(current_lines)) - in_block = False - current_lines = [] - else: - current_lines.append(line) - - return blocks - - -def find_orphaned_blocks( - checked_dirs: list[Path], - executable_blocks: set[str], -) -> list[dict[str, object]]: - """Находит python/pycon блоки в reference/explanations, не покрытые harness.""" - - gaps: list[dict[str, object]] = [] - for directory in checked_dirs: - if not directory.exists(): - continue - for md_file in sorted(directory.rglob("*.md")): - text = md_file.read_text(encoding="utf-8") - blocks = extract_fenced_blocks(text, {"python", "pycon"}) - for block in blocks: - if block.strip() not in executable_blocks: - gaps.append( - { - "file": str(md_file), - "block_preview": block.strip()[:120], - } - ) - return gaps - - -def main() -> int: - """Запускает проверку и возвращает код выхода.""" - - parser = argparse.ArgumentParser( - description="Проверяет orphaned python-блоки в reference/explanations" - ) - parser.add_argument("--output", default=None, help="Путь для JSON-отчёта") - parser.add_argument( - "--strict", - action="store_true", - help="Завершиться с кодом 1 при наличии gaps", - ) - args = parser.parse_args() - - executable_paths: list[Path] = [] - for name in EXECUTABLE_FILES: - executable_paths.append(Path(name)) - for d in EXECUTABLE_DIRS: - executable_paths.extend(sorted((DOCS_ROOT / d).rglob("*.md"))) - - executable_blocks = collect_executable_fences(executable_paths) - - checked_dirs = [DOCS_ROOT / d for d in CHECKED_DIRS] - gaps = find_orphaned_blocks(checked_dirs, executable_blocks) - - report = { - "checked_dirs": CHECKED_DIRS, - "executable_sources": [str(p) for p in executable_paths if p.exists()], - "gap_count": len(gaps), - "gaps": gaps, - } - - if args.output: - Path(args.output).write_text(json.dumps(report, indent=2, ensure_ascii=False)) - print(f"Отчёт сохранён в {args.output}") - - if gaps: - print( - f"Найдено {len(gaps)} orphaned python/pycon блок(а/ов) " - "в reference/ или explanations/:" - ) - for g in gaps: - print(f" {g['file']}") - print(f" {g['block_preview']!r}") - if args.strict: - return 1 - else: - print( - f"reference_explanation_examples_gaps=0 " - f"(проверено {len(checked_dirs)} директорий)" - ) - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/check_interrogate_gate.py b/scripts/check_interrogate_gate.py deleted file mode 100644 index c152520..0000000 --- a/scripts/check_interrogate_gate.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Interrogate diff-gate: проверяет, что изменённые модули не ухудшили покрытие docstrings. - -Использование: - python scripts/check_interrogate_gate.py [--baseline .interrogate-baseline] [--base-ref origin/main] - -Сравнивает текущее покрытие docstrings в каждом изменённом avito/*.py модуле с -зафиксированным baseline. Завершается с ненулевым кодом, если покрытие упало. -""" - -from __future__ import annotations - -import argparse -import json -import re -import subprocess -import sys -from pathlib import Path - - -def get_changed_modules(base_ref: str) -> list[str]: - """Возвращает список изменённых .py файлов в avito/ по сравнению с base_ref.""" - - result = subprocess.run( - ["git", "diff", "--name-only", base_ref], - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - print(f"Предупреждение: git diff завершился с кодом {result.returncode}", file=sys.stderr) - return [] - return [ - line.strip() - for line in result.stdout.splitlines() - if line.strip().startswith("avito/") and line.strip().endswith(".py") - ] - - -def get_module_coverage(module_path: str) -> float | None: - """Запускает interrogate для одного файла и возвращает процент покрытия.""" - - result = subprocess.run( - ["poetry", "run", "interrogate", module_path, "--fail-under=0", "-vv"], - capture_output=True, - text=True, - check=False, - ) - output = result.stdout + result.stderr - basename = Path(module_path).name - # Ищем строку с именем файла в Summary-таблице (целое %; совпадает с baseline). - match = re.search( - r"\|\s+" + re.escape(basename) + r"\s+\|\s+\d+\s+\|\s+\d+\s+\|\s+\d+\s+\|\s+(\d+)%", - output, - ) - if match: - return float(match.group(1)) - return None - - -def load_baseline(baseline_path: Path) -> dict[str, float]: - """Загружает baseline из JSON-файла.""" - - if not baseline_path.exists(): - return {} - with baseline_path.open() as f: - data = json.load(f) - return {k: float(v) for k, v in data.get("modules", {}).items()} - - -def main() -> int: - """Запускает interrogate diff-gate и возвращает код выхода.""" - - parser = argparse.ArgumentParser(description="Interrogate diff-gate против baseline") - parser.add_argument("--baseline", default=".interrogate-baseline", help="Путь к baseline-файлу") - parser.add_argument("--base-ref", default="origin/main", help="Git ref для сравнения") - args = parser.parse_args() - - baseline = load_baseline(Path(args.baseline)) - changed = get_changed_modules(args.base_ref) - - if not changed: - print("Нет изменённых avito/ модулей — gate пройден.") - return 0 - - failures: list[str] = [] - for module in changed: - current = get_module_coverage(module) - if current is None: - print(f" ПРОПУСК {module}: не удалось получить покрытие") - continue - - baseline_value = baseline.get(module) - if baseline_value is None: - print(f" НОВЫЙ {module}: {current:.0f}% (не в baseline)") - continue - - delta = current - baseline_value - status = "OK" if delta >= 0 else "УПАЛО" - print(f" {status:6s} {module}: {current:.0f}% (baseline {baseline_value:.0f}%, delta {delta:+.0f}%)") - if delta < 0: - failures.append(f"{module}: {current:.0f}% < baseline {baseline_value:.0f}%") - - if failures: - print(f"\nGate провален — покрытие упало в {len(failures)} модуле(ях):") - for f in failures: - print(f" - {f}") - return 1 - - print(f"\nGate пройден — {len(changed)} изменённых модулей, регрессий нет.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/check_inventory_coverage.py b/scripts/check_inventory_coverage.py deleted file mode 100644 index 655423a..0000000 --- a/scripts/check_inventory_coverage.py +++ /dev/null @@ -1,106 +0,0 @@ -from __future__ import annotations - -import argparse -import json -from dataclasses import asdict, dataclass -from pathlib import Path - -from parse_inventory import InventoryRow, parse_inventory -from public_sdk_surface import resolve_public_method - -ROOT = Path(__file__).resolve().parents[1] -DEFAULT_OUTPUT = ROOT / "inventory-coverage-report.json" - - -@dataclass(slots=True, frozen=True) -class InventoryGap: - document: str - method: str - path: str - sdk_package: str - domain_object: str - sdk_public_method: str - reason: str - - -def parse_version(value: str) -> tuple[int, int, int]: - parts = value.split(".") - if len(parts) != 3: - raise ValueError(value) - return int(parts[0]), int(parts[1]), int(parts[2]) - - -def removal_is_two_minor_later(deprecated_since: str, removal_version: str) -> bool: - since_major, since_minor, _ = parse_version(deprecated_since) - removal_major, removal_minor, _ = parse_version(removal_version) - return removal_major == since_major and removal_minor >= since_minor + 2 - - -def domain_has_public_method(row: InventoryRow) -> bool: - return resolve_public_method(row) is not None - - -def collect_gaps(rows: list[InventoryRow]) -> list[InventoryGap]: - gaps: list[InventoryGap] = [] - for row in rows: - if not domain_has_public_method(row): - gaps.append(gap(row, "не найден публичный SDK-символ")) - - if row.deprecated: - missing = [ - name - for name, value in ( - ("deprecated_since", row.deprecated_since), - ("replacement", row.replacement), - ("removal_version", row.removal_version), - ) - if value is None - ] - if missing: - gaps.append(gap(row, f"deprecated без обязательных полей: {', '.join(missing)}")) - elif not removal_is_two_minor_later(row.deprecated_since, row.removal_version): - gaps.append(gap(row, "removal_version раньше чем через два minor-релиза")) - - description_marks_deprecated = "deprecated" in row.description.lower() - if description_marks_deprecated and not row.deprecated: - gaps.append(gap(row, "описание содержит deprecated, но deprecated=нет")) - return gaps - - -def gap(row: InventoryRow, reason: str) -> InventoryGap: - return InventoryGap( - document=row.document, - method=row.method, - path=row.path, - sdk_package=row.sdk_package, - domain_object=row.domain_object, - sdk_public_method=row.sdk_public_method, - reason=reason, - ) - - -def write_report(rows: list[InventoryRow], gaps: list[InventoryGap], output: Path) -> None: - report = { - "total_operations": len(rows), - "deprecated_operations": sum(row.deprecated for row in rows), - "gaps": [asdict(item) for item in gaps], - "gap_count": len(gaps), - } - output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - - -def main() -> None: - parser = argparse.ArgumentParser(description="Проверить inventory coverage report-only.") - parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) - parser.add_argument("--strict", action="store_true") - args = parser.parse_args() - - rows = parse_inventory() - gaps = collect_gaps(rows) - write_report(rows, gaps, args.output) - if args.strict and gaps: - raise SystemExit(1) - - -if __name__ == "__main__": - main() diff --git a/scripts/check_public_docstrings.py b/scripts/check_public_docstrings.py deleted file mode 100644 index 009f01f..0000000 --- a/scripts/check_public_docstrings.py +++ /dev/null @@ -1,155 +0,0 @@ -from __future__ import annotations - -import argparse -import inspect -import json -from dataclasses import asdict, dataclass -from pathlib import Path - -from parse_inventory import InventoryRow, parse_inventory -from public_sdk_surface import resolve_public_method - -ROOT = Path(__file__).resolve().parents[1] -DEFAULT_OUTPUT = ROOT / "docstring-contract-report.json" -EXCEPTION_METADATA_FIELDS = ("operation", "status", "request_id", "attempt", "method", "endpoint") -OVERRIDE_PARAMS = ("timeout", "retries", "dry_run", "idempotency_key", "page_size") - - -@dataclass(slots=True, frozen=True) -class DocstringGap: - symbol: str - aspect: str - reason: str - - -def inventory_symbol_name(row: InventoryRow) -> str: - return f"avito.{row.sdk_package}.{row.domain_object}.{row.sdk_public_method}" - - -def public_parameters(method: object) -> set[str]: - try: - return set(inspect.signature(method).parameters) - except (TypeError, ValueError): - return set() - - -def has_return_annotation(method: object) -> bool: - try: - return inspect.signature(method).return_annotation is not inspect.Signature.empty - except (TypeError, ValueError): - return False - - -def needs_empty_behavior_note(method_name: str, doc: str, method: object) -> bool: - if any(marker in doc for marker in ("none", "null", "пуст", "empty")): - return False - if method_name.startswith("list") or method_name in {"get_items", "get_by_ids"}: - return True - try: - annotation = inspect.signature(method).return_annotation - except (TypeError, ValueError): - return False - return "PaginatedList" in str(annotation) or "list[" in str(annotation) - - -def doc_mentions_all(doc: str, markers: set[str]) -> bool: - return all(marker.lower() in doc for marker in markers) - - -def collect_gaps(rows: list[InventoryRow]) -> list[DocstringGap]: - gaps: list[DocstringGap] = [] - seen: set[str] = set() - for row in rows: - resolved = resolve_public_method(row) - symbol = resolved.symbol if resolved is not None else inventory_symbol_name(row) - if symbol in seen: - continue - seen.add(symbol) - - if resolved is None: - gaps.append(DocstringGap(symbol, "exists", "публичный метод не найден")) - continue - - doc = inspect.getdoc(resolved.method) or "" - lowered = doc.lower() - if not doc: - gaps.append(DocstringGap(symbol, "docstring", "docstring отсутствует")) - continue - - params = public_parameters(resolved.method) - override_params = params.intersection(OVERRIDE_PARAMS) - - if not has_return_annotation(resolved.method) and not any( - marker in lowered for marker in ("возвращ", "return", row.response_type.lower()) - ): - gaps.append(gap(symbol, "return_model")) - - if needs_empty_behavior_note(resolved.method_name, lowered, resolved.method): - gaps.append(gap(symbol, "nullable_empty")) - - if override_params and not doc_mentions_all(lowered, override_params): - gaps.append(gap(symbol, "overrides")) - - if "idempotency_key" in params and not any( - marker in lowered for marker in ("идемпот", "idempot", "idempotency_key") - ): - gaps.append(gap(symbol, "idempotency")) - - if not any( - marker in lowered - for marker in ("raises", "исключ", "ошиб", *EXCEPTION_METADATA_FIELDS) - ): - gaps.append(gap(symbol, "raises")) - - if "dry_run" in params and not any( - marker in lowered for marker in ("dry_run", "транспорт", "transport") - ): - gaps.append(gap(symbol, "dry_run")) - return gaps - - -def gap(symbol: str, aspect: str) -> DocstringGap: - return DocstringGap(symbol, aspect, "docstring не описывает обязательный contract-аспект") - - -def write_report(rows: list[InventoryRow], gaps: list[DocstringGap], output: Path) -> None: - by_aspect: dict[str, int] = {} - by_domain: dict[str, int] = {} - for item in gaps: - by_aspect[item.aspect] = by_aspect.get(item.aspect, 0) + 1 - parts = item.symbol.split(".") - domain = parts[1] if len(parts) > 1 else "unknown" - by_domain[domain] = by_domain.get(domain, 0) + 1 - - report = { - "checked_symbols": len( - { - resolved.symbol if (resolved := resolve_public_method(row)) is not None - else inventory_symbol_name(row) - for row in rows - } - ), - "required_exception_metadata_fields": list(EXCEPTION_METADATA_FIELDS), - "by_aspect": dict(sorted(by_aspect.items())), - "by_domain": dict(sorted(by_domain.items())), - "gaps": [asdict(gap) for gap in gaps], - "gap_count": len(gaps), - } - output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - - -def main() -> None: - parser = argparse.ArgumentParser(description="Проверить docstring-контракт публичных методов.") - parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) - parser.add_argument("--strict", action="store_true") - args = parser.parse_args() - - rows = parse_inventory() - gaps = collect_gaps(rows) - write_report(rows, gaps, args.output) - if args.strict and gaps: - raise SystemExit(1) - - -if __name__ == "__main__": - main() diff --git a/scripts/check_readme_domain_coverage.py b/scripts/check_readme_domain_coverage.py deleted file mode 100644 index bd9c0b0..0000000 --- a/scripts/check_readme_domain_coverage.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -import inspect -from pathlib import Path -from typing import get_type_hints - -from parse_inventory import parse_inventory - -ROOT = Path(__file__).resolve().parents[1] -README = ROOT / "README.md" -EXCLUDED_PACKAGES = {"auth", "core", "testing"} - - -def public_packages_from_inventory() -> set[str]: - return { - row.sdk_package - for row in parse_inventory() - if row.sdk_package and row.sdk_package not in EXCLUDED_PACKAGES - } - - -def factory_methods_by_package() -> dict[str, set[str]]: - from avito import AvitoClient - - factories: dict[str, set[str]] = {} - for name, member in inspect.getmembers(AvitoClient, predicate=inspect.isfunction): - if name.startswith("_"): - continue - annotation = get_type_hints(member).get("return") - module = getattr(annotation, "__module__", "") - if not module.startswith("avito."): - continue - package = module.split(".")[1] - factories.setdefault(package, set()).add(name) - return factories - - -def main() -> None: - readme = README.read_text(encoding="utf-8") - packages = public_packages_from_inventory() - factories = factory_methods_by_package() - - missing: list[str] = [] - for package in sorted(packages): - candidates = factories.get(package, set()) - if not candidates: - missing.append(f"{package}: нет фабричных методов AvitoClient") - continue - if not any(f"avito.{factory}(" in readme for factory in candidates): - missing.append(f"{package}: нет README-snippet с {', '.join(sorted(candidates))}") - - if missing: - print("README не покрывает домены из inventory:") - for item in missing: - print(f"- {item}") - raise SystemExit(1) - - print(f"README покрывает домены из inventory: {', '.join(sorted(packages))}") - - -if __name__ == "__main__": - main() diff --git a/scripts/check_reference_public_surface.py b/scripts/check_reference_public_surface.py deleted file mode 100644 index eaa3ee5..0000000 --- a/scripts/check_reference_public_surface.py +++ /dev/null @@ -1,140 +0,0 @@ -from __future__ import annotations - -import argparse -import importlib -import inspect -import json -from dataclasses import asdict, dataclass -from enum import Enum -from pathlib import Path - -from parse_inventory import parse_inventory - -ROOT = Path(__file__).resolve().parents[1] -DEFAULT_OUTPUT = ROOT / "reference-public-report.json" -REFERENCE_DIR = ROOT / "docs" / "site" / "reference" -EXCLUDED_PACKAGES = {"auth", "core", "testing"} -GENERATED_PAGES = {"operations.md", "enums.md"} - - -@dataclass(slots=True, frozen=True) -class ReferenceGap: - symbol: str - expected_page: str - reason: str - - -def domain_packages() -> list[str]: - return sorted( - { - row.sdk_package - for row in parse_inventory() - if row.sdk_package and row.sdk_package not in EXCLUDED_PACKAGES - } - ) - - -def public_exports(module_name: str) -> tuple[str, ...]: - module = importlib.import_module(module_name) - exports = getattr(module, "__all__", None) - if not isinstance(exports, tuple): - return () - return exports - - -def is_enum_symbol(module_name: str, name: str) -> bool: - module = importlib.import_module(module_name) - value = getattr(module, name, None) - return inspect.isclass(value) and issubclass(value, Enum) - - -def collect_gaps() -> list[ReferenceGap]: - gaps: list[ReferenceGap] = [] - - required_files = { - "AvitoClient": "client.md", - "AvitoClient.debug_info": "client.md", - "AvitoSettings": "config.md", - "AuthSettings": "config.md", - "factory_methods": "operations.md", - "public_models": "models.md", - "typed_exceptions": "exceptions.md", - "PaginatedList": "pagination.md", - "serialization": "models.md", - "testing": "testing.md", - } - for symbol, relative_page in required_files.items(): - if not page_is_available(relative_page): - gaps.append(ReferenceGap(symbol, relative_page, "reference-страница отсутствует")) - - from avito import AvitoClient - - if not callable(getattr(AvitoClient, "debug_info", None)): - gaps.append(ReferenceGap("AvitoClient.debug_info", "client.md", "публичный символ отсутствует")) - - packages = domain_packages() - for package in packages: - module_name = f"avito.{package}" - if not public_exports(module_name): - gaps.append( - ReferenceGap(module_name, f"domains/{package}.md", "__all__ отсутствует или пуст") - ) - for name in public_exports(module_name): - page = "enums.md" if is_enum_symbol(module_name, name) else f"domains/{package}.md" - if not page_is_available(page): - gaps.append(ReferenceGap(f"{module_name}.{name}", page, "reference-страница отсутствует")) - - for name in public_exports("avito.testing"): - if not (REFERENCE_DIR / "testing.md").exists(): - gaps.append(ReferenceGap(f"avito.testing.{name}", "testing.md", "страница отсутствует")) - - for name in public_exports("avito"): - expected = { - "AvitoClient": "client.md", - "AvitoSettings": "config.md", - "AuthSettings": "config.md", - "PaginatedList": "pagination.md", - }.get(name, "exceptions.md") - if not (REFERENCE_DIR / expected).exists(): - gaps.append(ReferenceGap(f"avito.{name}", expected, "страница отсутствует")) - - return gaps - - -def page_is_available(relative_page: str) -> bool: - if (REFERENCE_DIR / relative_page).exists(): - return True - if relative_page in GENERATED_PAGES: - return (ROOT / "docs" / "site" / "assets" / "_gen_reference.py").exists() - if relative_page.startswith("domains/"): - return (ROOT / "docs" / "site" / "assets" / "_gen_reference.py").exists() - return False - - -def write_report(gaps: list[ReferenceGap], output: Path) -> None: - packages = domain_packages() - report = { - "domain_packages": packages, - "domain_pages": [f"reference/domains/{package}.md" for package in packages], - "top_level_exports": list(public_exports("avito")), - "testing_exports": list(public_exports("avito.testing")), - "gaps": [asdict(gap) for gap in gaps], - "gap_count": len(gaps), - } - output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - - -def main() -> None: - parser = argparse.ArgumentParser(description="Проверить покрытие public surface в reference.") - parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) - parser.add_argument("--strict", action="store_true") - args = parser.parse_args() - - gaps = collect_gaps() - write_report(gaps, args.output) - if args.strict and gaps: - raise SystemExit(1) - - -if __name__ == "__main__": - main() diff --git a/scripts/check_spec_inventory_sync.py b/scripts/check_spec_inventory_sync.py deleted file mode 100644 index 8ea6f8d..0000000 --- a/scripts/check_spec_inventory_sync.py +++ /dev/null @@ -1,131 +0,0 @@ -from __future__ import annotations - -import argparse -import json -from collections import Counter -from dataclasses import asdict, dataclass -from pathlib import Path - -from parse_inventory import normalize_text, parse_documents, parse_inventory - -ROOT = Path(__file__).resolve().parents[1] -DEFAULT_OUTPUT = ROOT / "spec-inventory-report.json" -SPEC_DIR = ROOT / "docs" / "avito" / "api" -HTTP_METHODS = {"get", "post", "put", "delete", "patch"} - - -@dataclass(slots=True, frozen=True) -class OperationKey: - section: str - document: str - method: str - path: str - - -def normalize_path(value: str) -> str: - return ( - normalize_text(value) - .replace("\u200b", "") - .replace("\u200e", "") - .replace("\u200f", "") - .replace("\ufeff", "") - ) - - -def collect_spec_operations() -> Counter[OperationKey]: - documents = {normalize_text(row.document): row.section for row in parse_documents()} - operations: Counter[OperationKey] = Counter() - for path in sorted(SPEC_DIR.glob("*.json")): - document = normalize_text(path.name) - section = documents.get(document) - if section is None: - section = "" - payload = json.loads(path.read_text(encoding="utf-8")) - paths = payload.get("paths", {}) - if not isinstance(paths, dict): - continue - for raw_path, path_item in paths.items(): - if not isinstance(raw_path, str) or not isinstance(path_item, dict): - continue - for method in path_item: - if method.lower() not in HTTP_METHODS: - continue - operations[ - OperationKey( - section=section, - document=document, - method=method.upper(), - path=normalize_path(raw_path), - ) - ] += 1 - return operations - - -def collect_inventory_operations() -> Counter[OperationKey]: - operations: Counter[OperationKey] = Counter() - for row in parse_inventory(): - operations[ - OperationKey( - section=row.section, - document=normalize_text(row.document), - method=row.method, - path=normalize_path(row.path), - ) - ] += 1 - return operations - - -def counter_missing( - left: Counter[OperationKey], right: Counter[OperationKey] -) -> list[dict[str, str]]: - missing: list[dict[str, str]] = [] - for key, count in sorted( - (left - right).items(), - key=lambda item: ( - item[0].section, - item[0].document, - item[0].method, - item[0].path, - ), - ): - payload = asdict(key) - payload["count"] = str(count) - missing.append(payload) - return missing - - -def write_report( - spec_operations: Counter[OperationKey], - inventory_operations: Counter[OperationKey], - output: Path, -) -> tuple[int, int]: - missing_in_inventory = counter_missing(spec_operations, inventory_operations) - missing_in_spec = counter_missing(inventory_operations, spec_operations) - report = { - "spec_operation_count": spec_operations.total(), - "inventory_operation_count": inventory_operations.total(), - "missing_in_inventory": missing_in_inventory, - "missing_in_spec": missing_in_spec, - "gap_count": len(missing_in_inventory) + len(missing_in_spec), - } - output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - return len(missing_in_inventory), len(missing_in_spec) - - -def main() -> None: - parser = argparse.ArgumentParser(description="Сверить Swagger/OpenAPI specs с inventory.") - parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) - parser.add_argument("--strict", action="store_true") - args = parser.parse_args() - - spec_operations = collect_spec_operations() - inventory_operations = collect_inventory_operations() - missing_in_inventory, missing_in_spec = write_report( - spec_operations, inventory_operations, args.output - ) - if args.strict and (missing_in_inventory or missing_in_spec): - raise SystemExit(1) - - -if __name__ == "__main__": - main() diff --git a/scripts/download_avito_api_specs.py b/scripts/download_avito_api_specs.py new file mode 100644 index 0000000..cf791a3 --- /dev/null +++ b/scripts/download_avito_api_specs.py @@ -0,0 +1,162 @@ +"""Download Avito API OpenAPI specifications from the public developer portal.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +BASE_URL = "https://developers.avito.ru" +LIST_URL = f"{BASE_URL}/web/1/openapi/list" +INFO_URL_TEMPLATE = f"{BASE_URL}/web/1/openapi/info/{{slug}}" +DEFAULT_OUTPUT_DIR = Path("docs/avito/api") + + +@dataclass(frozen=True, slots=True) +class ApiCatalogItem: + slug: str + title: str + + +def run_curl(url: str) -> str: + result = subprocess.run( + ["curl", "-fsSL", url], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + message = result.stderr.strip() or f"curl завершился с кодом {result.returncode}" + raise RuntimeError(f"Не удалось скачать {url}: {message}") + return result.stdout + + +def load_json(raw: str, source: str) -> object: + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Источник {source} вернул некорректный JSON: {exc}") from exc + + +def get_string_field(data: object, field: str, source: str) -> str: + if not isinstance(data, dict): + raise RuntimeError(f"Источник {source} вернул объект неверного типа") + value = data.get(field) + if not isinstance(value, str) or not value: + raise RuntimeError(f"Источник {source} не содержит строковое поле {field!r}") + return value + + +def fetch_catalog() -> list[ApiCatalogItem]: + raw_catalog = load_json(run_curl(LIST_URL), LIST_URL) + if not isinstance(raw_catalog, list): + raise RuntimeError(f"Источник {LIST_URL} вернул не список API") + + catalog: list[ApiCatalogItem] = [] + for raw_item in raw_catalog: + slug = get_string_field(raw_item, "slug", LIST_URL) + title = get_string_field(raw_item, "title", LIST_URL) + catalog.append(ApiCatalogItem(slug=slug, title=title)) + return catalog + + +def fetch_swagger(slug: str) -> object: + source_url = INFO_URL_TEMPLATE.format(slug=slug) + raw_info = load_json(run_curl(source_url), source_url) + raw_swagger = get_string_field(raw_info, "swagger", source_url) + return load_json(raw_swagger, source_url) + + +def normalize_filename(title: str) -> str: + title_without_suffix = re.sub(r"\s*\([^)]*\)\s*$", "", title) + normalized = re.sub(r"\s+", "", title_without_suffix) + normalized = re.sub(r"[^\w\-\[\]]+", "", normalized, flags=re.UNICODE) + if not normalized: + raise RuntimeError(f"Не удалось нормализовать имя файла для {title!r}") + return f"{normalized}.json" + + +def save_spec(spec: object, destination: Path) -> None: + destination.write_text( + json.dumps(spec, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def remove_stale_specs(output_dir: Path, expected_files: set[Path]) -> int: + removed_count = 0 + for path in output_dir.glob("*.json"): + if path not in expected_files: + path.unlink() + print(f"Удалена устаревшая спецификация: {path}") + removed_count += 1 + return removed_count + + +def download_specs(output_dir: Path, dry_run: bool, clean: bool) -> int: + catalog = fetch_catalog() + output_dir.mkdir(parents=True, exist_ok=True) + expected_files = {output_dir / normalize_filename(item.title) for item in catalog} + + if clean and not dry_run: + remove_stale_specs(output_dir, expected_files) + + saved_count = 0 + for item in catalog: + destination = output_dir / normalize_filename(item.title) + if dry_run: + print(f"{item.slug}: {destination}") + continue + + spec = fetch_swagger(item.slug) + save_spec(spec, destination) + print(f"{item.slug}: {destination}") + saved_count += 1 + + return saved_count + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Скачать Swagger/OpenAPI спецификации Авито в docs/avito/api.", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=DEFAULT_OUTPUT_DIR, + help="Каталог для сохранения спецификаций.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Показать целевые имена файлов без скачивания спецификаций.", + ) + parser.add_argument( + "--clean", + action="store_true", + help="Удалить локальные JSON-файлы, которых больше нет в upstream catalog.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + saved_count = download_specs(args.output_dir, args.dry_run, args.clean) + except RuntimeError as exc: + print(exc, file=sys.stderr) + return 1 + + if args.dry_run: + return 0 + + print(f"Скачано спецификаций: {saved_count}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lint_swagger_bindings.py b/scripts/lint_swagger_bindings.py new file mode 100644 index 0000000..bd437a3 --- /dev/null +++ b/scripts/lint_swagger_bindings.py @@ -0,0 +1,97 @@ +"""Validate local Swagger/OpenAPI corpus and report SDK binding coverage.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from avito.core.swagger_discovery import discover_swagger_bindings +from avito.core.swagger_factory_map import build_factory_domain_mapping_report +from avito.core.swagger_linter import lint_swagger_bindings +from avito.core.swagger_registry import ( + DEFAULT_SWAGGER_API_DIR, + SwaggerRegistryError, + load_swagger_registry, +) +from avito.core.swagger_report import build_swagger_binding_report + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Проверить локальные Swagger/OpenAPI specs для SDK bindings.", + ) + parser.add_argument( + "--api-dir", + type=Path, + default=DEFAULT_SWAGGER_API_DIR, + help="Каталог с Swagger/OpenAPI JSON specs.", + ) + parser.add_argument( + "--json", + action="store_true", + dest="json_report", + help="Вывести baseline coverage report в JSON.", + ) + parser.add_argument( + "--strict", + action="store_true", + help="Требовать ровно один SDK binding для каждой Swagger operation.", + ) + parser.add_argument( + "--output", + type=Path, + help="Записать JSON report в файл вместо stdout.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + registry = load_swagger_registry(args.api_dir) + except SwaggerRegistryError as exc: + print(exc, file=sys.stderr) + return 2 + + discovery = discover_swagger_bindings(registry=registry) + lint_errors = lint_swagger_bindings(registry, discovery, strict=args.strict) + factory_mapping = build_factory_domain_mapping_report() + report = build_swagger_binding_report( + registry, + discovery, + errors=lint_errors, + factory_mapping=factory_mapping, + ) + report_data = report.to_dict() + + if args.json_report: + # `json.dumps()` принимает JSON-compatible структуру на границе CLI. + output = json.dumps(report_data, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + if args.output is None: + print(output, end="") + else: + args.output.write_text(output, encoding="utf-8") + return 1 if registry.errors or lint_errors else 0 + + summary = report_data["summary"] + if not isinstance(summary, dict): + raise TypeError("Swagger report summary must be a JSON object.") + print( + "Swagger specs: " + f"{len(registry.specs)}, operations: {len(registry.operations)}, " + f"deprecated operations: {len(registry.deprecated_operations)}, " + f"bound: {summary['bound']}, unbound: {summary['unbound']}, " + f"duplicate: {summary['duplicate']}, ambiguous: {summary['ambiguous']}, " + f"validation errors: {len(registry.errors)}" + ) + for error in registry.errors: + print(f"[{error.code}] {error.message}", file=sys.stderr) + for error in lint_errors: + print(f"[{error.code}] {error.message}", file=sys.stderr) + return 1 if registry.errors or lint_errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/parse_inventory.py b/scripts/parse_inventory.py deleted file mode 100644 index 5b83afd..0000000 --- a/scripts/parse_inventory.py +++ /dev/null @@ -1,182 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import unicodedata -from dataclasses import asdict, dataclass -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -DEFAULT_INVENTORY_PATH = ROOT / "docs" / "avito" / "inventory.md" - - -@dataclass(slots=True, frozen=True) -class DocumentRow: - document: str - section: str - sdk_package: str - default_domain_object: str - operations_count: int - - -@dataclass(slots=True, frozen=True) -class InventoryRow: - section: str - document: str - method: str - path: str - description: str - deprecated: bool - deprecated_since: str | None - replacement: str | None - removal_version: str | None - sdk_package: str - domain_object: str - sdk_public_method: str - request_type: str - response_type: str - test_type: str - notes: str | None - - -def normalize_text(value: str) -> str: - return unicodedata.normalize("NFC", value).strip() - - -def parse_optional(value: str) -> str | None: - normalized = normalize_text(value).strip("`") - return normalized or None - - -def parse_bool(value: str) -> bool: - normalized = normalize_text(value).lower() - if normalized == "да": - return True - if normalized == "нет": - return False - raise ValueError(f"Недопустимое значение deprecated: {value!r}") - - -def parse_markdown_table(line: str) -> list[str]: - return [normalize_text(cell).strip("`") for cell in line.strip().strip("|").split("|")] - - -def read_table( - lines: list[str], marker: str | None = None, heading: str | None = None -) -> list[str]: - start = None - if marker is not None: - for index, line in enumerate(lines): - if marker in line: - start = index + 1 - break - elif heading is not None: - for index, line in enumerate(lines): - if line.strip() == heading: - start = index + 1 - break - if start is None: - return [] - - table: list[str] = [] - for line in lines[start:]: - if line.startswith("|"): - table.append(line) - continue - if table: - break - return table - - -def parse_documents(path: Path = DEFAULT_INVENTORY_PATH) -> list[DocumentRow]: - lines = path.read_text(encoding="utf-8").splitlines() - table = read_table(lines, heading="## Соответствие Документов И SDK") - rows: list[DocumentRow] = [] - for line in table[2:]: - cells = parse_markdown_table(line) - if len(cells) != 5: - raise ValueError(f"Некорректная строка таблицы документов: {line}") - document, section, sdk_package, default_domain_object, operations_count = cells - rows.append( - DocumentRow( - document=document, - section=section, - sdk_package=sdk_package, - default_domain_object=default_domain_object, - operations_count=int(operations_count.rstrip(":")), - ) - ) - return rows - - -def parse_inventory(path: Path = DEFAULT_INVENTORY_PATH) -> list[InventoryRow]: - lines = path.read_text(encoding="utf-8").splitlines() - table = read_table(lines, marker="") - if len(table) < 2: - raise ValueError("Таблица операций не найдена.") - - headers = parse_markdown_table(table[0]) - expected_headers = [ - "раздел", - "документ", - "метод", - "путь", - "описание", - "deprecated", - "deprecated_since", - "replacement", - "removal_version", - "пакет_sdk", - "доменный_объект", - "публичный_метод_sdk", - "тип_запроса", - "тип_ответа", - "тип_теста", - "примечания", - ] - if headers != expected_headers: - raise ValueError(f"Неожиданные колонки inventory: {headers!r}") - - rows: list[InventoryRow] = [] - for line in table[2:]: - if line.startswith(""): - break - cells = parse_markdown_table(line) - if len(cells) != len(expected_headers): - raise ValueError(f"Некорректная строка operations table: {line}") - deprecated = parse_bool(cells[5]) - rows.append( - InventoryRow( - section=cells[0], - document=cells[1], - method=cells[2].upper(), - path=cells[3], - description=cells[4], - deprecated=deprecated, - deprecated_since=parse_optional(cells[6]), - replacement=parse_optional(cells[7]), - removal_version=parse_optional(cells[8]), - sdk_package=cells[9], - domain_object=cells[10], - sdk_public_method=cells[11], - request_type=cells[12], - response_type=cells[13], - test_type=cells[14], - notes=parse_optional(cells[15]), - ) - ) - return rows - - -def main() -> None: - parser = argparse.ArgumentParser(description="Разобрать docs/avito/inventory.md.") - parser.add_argument("--inventory", type=Path, default=DEFAULT_INVENTORY_PATH) - parser.add_argument("--documents", action="store_true") - args = parser.parse_args() - - rows = parse_documents(args.inventory) if args.documents else parse_inventory(args.inventory) - print(json.dumps([asdict(row) for row in rows], ensure_ascii=False, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/scripts/public_sdk_surface.py b/scripts/public_sdk_surface.py deleted file mode 100644 index f8f60f8..0000000 --- a/scripts/public_sdk_surface.py +++ /dev/null @@ -1,241 +0,0 @@ -from __future__ import annotations - -import importlib -import inspect -from collections.abc import Callable -from dataclasses import dataclass - -try: - from parse_inventory import InventoryRow -except ModuleNotFoundError: - from scripts.parse_inventory import InventoryRow - - -@dataclass(slots=True, frozen=True) -class PublicMethod: - sdk_package: str - domain_object: str - method_name: str - method: object - - @property - def symbol(self) -> str: - return f"avito.{self.sdk_package}.{self.domain_object}.{self.method_name}" - - -MethodAlias = Callable[[InventoryRow], str | None] - - -EXPLICIT_METHOD_ALIASES: dict[tuple[str, str, str], str] = { - ("accounts", "Account", "get_user_info_self"): "get_self", - ("accounts", "Account", "get_user_balance"): "get_balance", - ("accounts", "AccountHierarchy", "get_check_ah_user_v1"): "get_status", - ("accounts", "AccountHierarchy", "list_employees_v1"): "list_employees", - ("accounts", "AccountHierarchy", "create_link_items_v1"): "link_items", - ("accounts", "AccountHierarchy", "list_company_phones_v1"): "list_company_phones", - ("accounts", "AccountHierarchy", "list_items_by_employee_id_v1"): "list_items_by_employee", - ("ads", "Ad", "get_item_info"): "get", - ("ads", "Ad", "get_items_info"): "list", - ("ads", "Ad", "update_update_price"): "update_price", - ("ads", "AdPromotion", "update_item_vas"): "apply_vas_direct", - ("ads", "AdPromotion", "update_item_vas_package_v2"): "apply_vas_package", - ("ads", "AdPromotion", "update_apply_vas"): "apply_vas", - ("ads", "AdStats", "get_item_stats_shallow"): "get_item_stats", - ("ads", "AutoloadProfile", "create_upload"): "upload_by_url", - ("ads", "AutoloadProfile", "get_user_docs_node_fields"): "get_node_fields", - ("ads", "AutoloadProfile", "get_user_docs_tree"): "get_tree", - ("ads", "AutoloadProfile", "get_profile_v2"): "get", - ("ads", "AutoloadProfile", "create_or_update_profile_v2"): "save", - ("ads", "AutoloadReport", "list_reports_v2"): "list", - ("ads", "AutoloadReport", "get_autoload_items_info_v2"): "get_items_info", - ("ads", "AutoloadReport", "get_report_items_by_id"): "get_items", - ("ads", "AutoloadReport", "get_report_items_fees_by_id"): "get_fees", - ("ads", "AutoloadReport", "get_last_completed_report_v3"): "get_last_completed", - ("ads", "AutoloadReport", "get_report_by_id_v3"): "get", - ("autoteka", "AutotekaVehicle", "get_catalogs_resolve"): "resolve_catalog", - ("autoteka", "AutotekaMonitoring", "list_monitoring_bucket_delete"): "remove_bucket", - ("autoteka", "AutotekaMonitoring", "delete_monitoring_bucket_remove"): "delete_bucket", - ( - "autoteka", - "AutotekaMonitoring", - "get_monitoring_get_reg_actions", - ): "get_monitoring_reg_actions", - ("autoteka", "AutotekaReport", "list_report_list"): "list_reports", - ("autoteka", "AutotekaScoring", "get_scoring_get_by_id"): "get_scoring_by_id", - ("autoteka", "AutotekaVehicle", "get_specification_get_by_id"): "get_specification_by_id", - ( - "autoteka", - "AutotekaReport", - "create_sync_create_report_by_reg_number", - ): "create_sync_report_by_reg_number", - ( - "autoteka", - "AutotekaReport", - "create_sync_create_report_by_vin", - ): "create_sync_report_by_vin", - ("cpa", "CpaChat", "get_chat_by_action_id"): "get", - ("cpa", "CpaCall", "create_create_complaint"): "create_complaint", - ("cpa", "CpaCall", "create_calls_by_time_v2"): "list", - ("cpa", "CpaChat", "create_chats_by_time"): "list", - ("cpa", "CpaLead", "create_balance_info_v3"): "get_balance_info", - ("cpa", "CallTrackingCall", "create_call_by_id"): "get", - ("cpa", "CallTrackingCall", "create_calls"): "list", - ("cpa", "CallTrackingCall", "get_record_by_call_id"): "download", - ("jobs", "Application", "get_applications_apply_actions"): "apply", - ("jobs", "Application", "list_applications_get_by_ids"): "list", - ("jobs", "Application", "list_applications_get_ids"): "list", - ("jobs", "Application", "list_applications_get_states"): "get_states", - ("jobs", "Application", "get_applications_set_is_viewed"): "update", - ("jobs", "JobWebhook", "delete_applications_webhook_delete"): "delete", - ("jobs", "JobWebhook", "get_applications_webhook_get"): "get", - ("jobs", "JobWebhook", "update_applications_webhook_put"): "update", - ("jobs", "JobWebhook", "list_applications_webhooks_get"): "list", - ("jobs", "Resume", "list_resumes_get"): "list", - ("jobs", "Resume", "get_resume_get_contacts"): "get_contacts", - ("jobs", "Resume", "get_resume_get_item"): "get", - ("jobs", "Vacancy", "create_vacancy_create"): "create", - ("jobs", "Vacancy", "delete_vacancy_archive"): "delete", - ("jobs", "Vacancy", "update_vacancy_update"): "update", - ("jobs", "Vacancy", "create_vacancy_prolongate"): "prolongate", - ("jobs", "Vacancy", "list_search_vacancy"): "list", - ("jobs", "Vacancy", "create_vacancy_create_v2"): "create", - ("jobs", "Vacancy", "get_vacancies_get_by_ids"): "get_by_ids", - ("jobs", "Vacancy", "get_vacancy_get_statuses"): "get_statuses", - ("jobs", "Vacancy", "update_vacancy_update_v2"): "update", - ("jobs", "Vacancy", "get_vacancy_get_item"): "get", - ("jobs", "Vacancy", "update_vacancy_auto_renewal"): "update_auto_renewal", - ("jobs", "JobDictionary", "list_dicts"): "list", - ("jobs", "JobDictionary", "list_dict_by_id"): "get", - ("messenger", "ChatMessage", "create_send_message"): "send_message", - ("messenger", "ChatMessage", "create_send_image_message"): "send_image", - ("messenger", "ChatMessage", "delete_message"): "delete", - ("messenger", "Chat", "create_chat_read"): "mark_read", - ("messenger", "ChatMedia", "create_upload_images"): "upload_images", - ("messenger", "ChatWebhook", "get_subscriptions"): "list", - ("messenger", "ChatWebhook", "delete_webhook_unsubscribe"): "unsubscribe", - ("messenger", "Chat", "create_blacklist_v2"): "blacklist", - ("messenger", "Chat", "get_chats_v2"): "list", - ("messenger", "Chat", "get_chat_by_id_v2"): "get", - ("messenger", "ChatMessage", "list_messages_v3"): "list", - ("messenger", "ChatWebhook", "update_webhook_v3"): "subscribe", - ("messenger", "SpecialOfferCampaign", "create_multi_confirm"): "confirm_multi", - ("messenger", "SpecialOfferCampaign", "create_multi_create"): "create_multi", - ("orders", "DeliveryOrder", "delete_cancel_announcement3_pl"): "delete", - ("orders", "DeliveryOrder", "create_announcement3_pl"): "create_announcement", - ("orders", "DeliveryOrder", "create_parcel"): "create", - ("orders", "DeliveryTask", "get_task"): "get", - ("orders", "Order", "create_accept_return_order"): "accept_return_order", - ("orders", "Order", "get_apply_transition"): "apply", - ("orders", "Order", "create_check_confirmation_code"): "check_confirmation_code", - ("orders", "Order", "create_cnc_set_details"): "set_cnc_details", - ("orders", "Order", "get_set_courier_delivery_range"): "set_courier_delivery_range", - ("orders", "Order", "update_set_order_tracking_number"): "update_tracking_number", - ("orders", "Order", "get_orders"): "list", - ("orders", "OrderLabel", "create_generate_labels"): "create", - ("orders", "OrderLabel", "create_generate_labels_extended"): "create", - ("orders", "OrderLabel", "get_download_label"): "download", - ("orders", "Stock", "get_получение_остатков"): "get", - ("orders", "Stock", "update_редактирование_остатков"): "update", - ("promotion", "TrxPromotion", "create_trx_promo_open_api_apply"): "apply", - ("promotion", "TrxPromotion", "delete_trx_promo_open_api_cancel"): "delete", - ("promotion", "TrxPromotion", "get_trx_promo_open_api_commissions"): "get_commissions", - ("promotion", "AutostrategyCampaign", "create_autostrategy_budget"): "create_budget", - ("promotion", "AutostrategyCampaign", "create_autostrategy_campaign"): "create", - ( - "promotion", - "AutostrategyCampaign", - "update_edit_autostrategy_campaign", - ): "update", - ( - "promotion", - "AutostrategyCampaign", - "get_autostrategy_campaign_info", - ): "get", - ( - "promotion", - "AutostrategyCampaign", - "delete_stop_autostrategy_campaign", - ): "delete", - ("promotion", "AutostrategyCampaign", "list_autostrategy_campaigns"): "list", - ("promotion", "AutostrategyCampaign", "get_autostrategy_stat"): "get_stat", - ("promotion", "TargetActionPricing", "delete_promotion"): "delete", - ("promotion", "TargetActionPricing", "update_auto_bid"): "update_auto", - ("promotion", "TargetActionPricing", "update_manual_bid"): "update_manual", - ("promotion", "BbipPromotion", "create_bbip_forecasts_by_items_v1"): "get_forecasts", - ("promotion", "BbipPromotion", "update_bbip_order_for_items_v1"): "create_order", - ("promotion", "BbipPromotion", "create_bbip_suggests_by_items_v1"): "get_suggests", - ("promotion", "PromotionOrder", "create_dict_of_services_v1"): "get_service_dictionary", - ("promotion", "PromotionOrder", "list_services_by_items_v1"): "list_services", - ("promotion", "PromotionOrder", "list_orders_by_user_v1"): "list_orders", - ("promotion", "PromotionOrder", "get_order_status_v1"): "get_order_status", - ( - "realty", - "RealtyAnalyticsReport", - "get_market_price_correspondence_v1", - ): "get_market_price_correspondence", - ("ratings", "ReviewAnswer", "create_review_answer_v1"): "create", - ("ratings", "ReviewAnswer", "delete_review_answer_v1"): "delete", - ("ratings", "RatingProfile", "get_ratings_info_v1"): "get", - ("ratings", "Review", "list_reviews_v1"): "list", -} - -SANDBOX_DELIVERY_ALIASES: dict[str, str] = { - "create_track_announcement": "track_announcement", - "delete_cancel_parcel": "cancel_parcel", - "get_check_confirmation_code": "check_confirmation_code", - "create_set_order_properties": "set_order_properties", - "create_set_order_real_address": "set_order_real_address", - "create_tracking": "tracking", - "delete_prohibit_order_acceptance": "prohibit_order_acceptance", - "create_add_sorting_center": "add_sorting_center", - "create_add_areas_sandbox": "add_areas", - "update_add_tags_to_sorting_center": "add_tags_to_sorting_center", - "create_add_terminals_sandbox": "add_terminals", - "update_update_terms": "update_terms", - "create_add_tariff_sandbox_v2": "add_tariff", - "create_v1cancel_announcement": "cancel_sandbox_announcement", - "delete_v1_cancel_parcel": "cancel_sandbox_parcel", - "create_v1change_parcel": "change_sandbox_parcel", - "create_v1create_announcement": "create_sandbox_announcement", - "get_v1get_announcement_event": "get_sandbox_announcement_event", - "get_v1get_change_parcel_info": "get_sandbox_change_parcel_info", - "get_v1get_parcel_info": "get_sandbox_parcel_info", - "get_v1get_registered_parcel_id": "get_sandbox_registered_parcel_id", - "create_sandbox_parcel_v2": "create_parcel", -} - - -def resolve_public_method(row: InventoryRow) -> PublicMethod | None: - if row.domain_object == "AvitoClient.auth()": - from avito import AvitoClient - - method = getattr(AvitoClient, "auth", None) - if method is None: - return None - return PublicMethod("client", "AvitoClient", "auth", method) - - try: - module = importlib.import_module(f"avito.{row.sdk_package}") - except ModuleNotFoundError: - return None - - domain_class = getattr(module, row.domain_object, None) - if domain_class is None or not inspect.isclass(domain_class): - return None - - method_name = public_method_name(row) - method = getattr(domain_class, method_name, None) - if method is None: - return None - return PublicMethod(row.sdk_package, row.domain_object, method_name, method) - - -def public_method_name(row: InventoryRow) -> str: - explicit = EXPLICIT_METHOD_ALIASES.get( - (row.sdk_package, row.domain_object, row.sdk_public_method) - ) - if explicit is not None: - return explicit - if row.sdk_package == "orders" and row.domain_object == "SandboxDelivery": - return SANDBOX_DELIVERY_ALIASES.get(row.sdk_public_method, row.sdk_public_method) - return row.sdk_public_method diff --git a/tests/contracts/test_client_contracts.py b/tests/contracts/test_client_contracts.py index e255efc..d602a5a 100644 --- a/tests/contracts/test_client_contracts.py +++ b/tests/contracts/test_client_contracts.py @@ -149,6 +149,9 @@ def handler(request: httpx.Request) -> httpx.Response: assert isinstance(summary, ListingHealthSummary) assert summary.total_listings == 1 + assert summary.loaded_listings == 1 + assert summary.listing_limit == 50 + assert summary.is_complete is True assert summary.active_listings == 1 assert summary.visible_listings == 1 assert summary.total_views == 45 @@ -189,6 +192,9 @@ def handler(request: httpx.Request) -> httpx.Response: summary = client.listing_health() assert summary.total_listings == 1 + assert summary.loaded_listings == 1 + assert summary.listing_limit == 50 + assert summary.is_complete is True assert summary.total_views == 45 assert summary.total_calls == 3 assert summary.total_spendings is None @@ -202,6 +208,37 @@ def handler(request: httpx.Request) -> httpx.Response: client.close() +def test_listing_health_keeps_unknown_total_separate_from_loaded_count() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/core/v1/items": + assert request.url.params["per_page"] == "50" + assert request.url.params["page"] == "1" + return httpx.Response( + 200, + json={"items": [{"id": item_id, "status": "active"} for item_id in range(101, 126)]}, + ) + if request.url.path == "/stats/v1/accounts/7/items": + return httpx.Response(200, json={"items": []}) + if request.url.path == "/core/v1/accounts/7/calls/stats/": + return httpx.Response(200, json={"items": []}) + if request.url.path == "/stats/v2/accounts/7/spendings": + return httpx.Response(200, json={"items": []}) + raise AssertionError(request.url.path) + + client = AvitoClient( + AvitoSettings(auth=AuthSettings(client_id="client-id", client_secret="client-secret")) + ) + client.transport = make_transport(httpx.MockTransport(handler), user_id=7) + + summary = client.listing_health(limit=50, page_size=50) + + assert summary.loaded_listings == 25 + assert summary.total_listings is None + assert summary.listing_limit == 50 + assert summary.is_complete is False + client.close() + + def test_account_health_builds_final_business_summary() -> None: def handler(request: httpx.Request) -> httpx.Response: path = request.url.path diff --git a/tests/contracts/test_deprecation_warnings.py b/tests/contracts/test_deprecation_warnings.py deleted file mode 100644 index 7cea0da..0000000 --- a/tests/contracts/test_deprecation_warnings.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -import warnings -from collections.abc import Callable - -import httpx -import pytest - -from avito.ads import AutoloadArchive -from avito.core.deprecation import _WARNED_SYMBOLS -from avito.cpa import CpaArchive, CpaChat -from scripts.parse_inventory import InventoryRow, parse_inventory -from tests.helpers.transport import make_transport - - -def response_for(path: str) -> httpx.Response: - if path == "/cpa/v1/call/101": - return httpx.Response(200, content=b"ID3", headers={"content-type": "audio/mpeg"}) - if path == "/cpa/v1/chatsByTime": - return httpx.Response(200, json={"chats": []}) - if path == "/cpa/v2/balanceInfo": - return httpx.Response(200, json={"balance": -5000, "advance": 1000, "debt": 0}) - if path == "/cpa/v2/callById": - return httpx.Response(200, json={"calls": {"id": 101}}) - if path == "/autoload/v1/profile": - return httpx.Response(200, json={"userId": 7, "isEnabled": True, "uploadUrl": "https://example.test/upload"}) - if path == "/autoload/v2/reports/last_completed_report": - return httpx.Response(200, json={"reportId": 11, "status": "completed"}) - if path == "/autoload/v2/reports/101": - return httpx.Response(200, json={"reportId": 101, "status": "completed"}) - raise AssertionError(f"Неожиданный маршрут теста deprecated: {path}") - - -def make_deprecated_transport() -> object: - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/autoload/v1/profile" and request.method == "POST": - return httpx.Response(200, json={"success": True}) - return response_for(request.url.path) - - return make_transport(httpx.MockTransport(handler)) - - -def deprecated_cases() -> list[tuple[InventoryRow, Callable[[], object]]]: - transport = make_deprecated_transport() - cpa_archive = CpaArchive(transport, call_id=101) - cpa_chat = CpaChat(transport) - autoload_archive = AutoloadArchive(transport, report_id=101) - calls: dict[tuple[str, str], Callable[[], object]] = { - ("cpa", "CpaArchive.get_call"): lambda: cpa_archive.get_call(), - ("cpa", "CpaChat.list"): lambda: cpa_chat.list( - created_at_from="2026-04-18T00:00:00+03:00", - version=1, - ), - ("cpa", "CpaArchive.get_balance_info"): lambda: cpa_archive.get_balance_info(), - ("cpa", "CpaArchive.get_call_by_id"): lambda: cpa_archive.get_call_by_id(call_id=101), - ("ads", "AutoloadArchive.get_profile"): lambda: autoload_archive.get_profile(), - ("ads", "AutoloadArchive.save_profile"): lambda: autoload_archive.save_profile(is_enabled=True), - ("ads", "AutoloadArchive.get_last_completed_report"): ( - lambda: autoload_archive.get_last_completed_report() - ), - ("ads", "AutoloadArchive.get_report"): lambda: autoload_archive.get_report(), - } - - cases: list[tuple[InventoryRow, Callable[[], object]]] = [] - for row in parse_inventory(): - if not row.deprecated: - continue - key = (row.sdk_package, f"{row.domain_object}.{row.sdk_public_method}") - if key not in calls: - raise AssertionError(f"Нет deprecated-test case для {key}") - cases.append((row, calls[key])) - return cases - - -@pytest.mark.parametrize(("row", "call"), deprecated_cases()) -def test_deprecated_inventory_symbols_warn_once( - row: InventoryRow, - call: Callable[[], object], -) -> None: - _WARNED_SYMBOLS.clear() - - with warnings.catch_warnings(record=True) as recorded: - warnings.simplefilter("always", DeprecationWarning) - call() - call() - - deprecation_warnings = [ - warning for warning in recorded if issubclass(warning.category, DeprecationWarning) - ] - assert len(deprecation_warnings) == 1 - - message = str(deprecation_warnings[0].message) - assert row.replacement is not None - assert row.removal_version is not None - assert row.deprecated_since is not None - assert row.replacement in message - assert row.removal_version in message - assert row.deprecated_since in message diff --git a/tests/contracts/test_public_surface.py b/tests/contracts/test_public_surface.py index 260895a..418d56a 100644 --- a/tests/contracts/test_public_surface.py +++ b/tests/contracts/test_public_surface.py @@ -36,7 +36,7 @@ from avito.messenger import ChatMedia from avito.orders import DeliveryOrder, Order, OrderLabel, SandboxDelivery, Stock from avito.realty import RealtyBooking, RealtyListing, RealtyPricing -from avito.testing import FakeResponse, FakeTransport +from avito.testing import FakeResponse, FakeTransport, SwaggerFakeTransport MODEL_MODULES = ( "avito.accounts.models", @@ -93,6 +93,7 @@ def test_top_level_package_exports_canonical_error_contract() -> None: def test_testing_package_exports_fake_transport_contract() -> None: assert FakeTransport.__module__ == "avito.testing.fake_transport" + assert SwaggerFakeTransport.__module__ == "avito.testing.swagger_fake_transport" assert FakeResponse.__module__ == "httpx" diff --git a/tests/contracts/test_swagger_contracts.py b/tests/contracts/test_swagger_contracts.py new file mode 100644 index 0000000..f7a6ce2 --- /dev/null +++ b/tests/contracts/test_swagger_contracts.py @@ -0,0 +1,336 @@ +from __future__ import annotations + +import warnings +from collections.abc import Iterator + +import pytest + +from avito.accounts.models import AccountProfile, EmployeeItem +from avito.core.deprecation import _WARNED_SYMBOLS +from avito.core.exceptions import ( + AuthenticationError, + AuthorizationError, + ConflictError, + NotFoundError, + RateLimitError, + ServerError, + UpstreamApiError, + ValidationError, +) +from avito.core.pagination import PaginatedList +from avito.core.swagger_discovery import DiscoveredSwaggerBinding, discover_swagger_bindings +from avito.core.swagger_registry import SwaggerOperation, SwaggerRegistry, load_swagger_registry +from avito.testing import SwaggerFakeTransport, error_payload + +_REGISTRY = load_swagger_registry() +_DISCOVERY = discover_swagger_bindings(registry=_REGISTRY) +_BINDINGS = _DISCOVERY.bindings +_BINDING_BY_OPERATION = _DISCOVERY.canonical_map + + +def _binding_id(binding: DiscoveredSwaggerBinding) -> str: + return binding.operation_key or binding.sdk_method + + +def _error_status_cases() -> tuple[ + tuple[SwaggerOperation, DiscoveredSwaggerBinding, int, type[Exception]], + ..., +]: + cases: list[tuple[SwaggerOperation, DiscoveredSwaggerBinding, int, type[Exception]]] = [] + 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), + ) + ) + return tuple(cases) + + +def _error_status_id( + case: tuple[SwaggerOperation, DiscoveredSwaggerBinding, int, type[Exception]], +) -> str: + operation, _binding, status_code, _expected_error = case + return f"{operation.key} {status_code}" + + +def _expected_exception_type( + status_code: int, + binding: DiscoveredSwaggerBinding, +) -> type[Exception]: + if binding.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 == 404: + return NotFoundError + if status_code == 409: + return ConflictError + if status_code == 422: + return ValidationError + if status_code == 429: + return RateLimitError + if status_code >= 500: + return ServerError + return UpstreamApiError + + +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] + assert len(matches) == 1 + return matches[0] + + +def test_swagger_contract_coverage_matches_discovered_bindings() -> None: + assert len(_BINDINGS) == len(_REGISTRY.operations) == 204 + assert len(_BINDING_BY_OPERATION) == len(_REGISTRY.operations) + + +@pytest.mark.parametrize("binding", _BINDINGS, ids=_binding_id) +def test_swagger_fake_transport_invokes_every_discovered_binding( + binding: DiscoveredSwaggerBinding, +) -> None: + fake = SwaggerFakeTransport(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) + fake.invoke_binding(binding) + + assert fake.count() >= 1 + + +def test_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.parametrize("case", _error_status_cases(), ids=_error_status_id) +def test_swagger_fake_transport_maps_every_declared_error_status( + case: tuple[SwaggerOperation, DiscoveredSwaggerBinding, int, type[Exception]], +) -> None: + operation, binding, status_code, expected_error = case + fake = SwaggerFakeTransport(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) + fake.invoke_binding(binding) + + assert exc_info.value.args[0] == f"Ошибка {status_code}" + + +def test_swagger_deprecated_contract_covers_all_deprecated_operations() -> None: + deprecated_bindings = tuple(binding for binding in _BINDINGS if binding.deprecated) + + assert len(deprecated_bindings) == len(_REGISTRY.deprecated_operations) == 7 + for binding in deprecated_bindings: + _WARNED_SYMBOLS.clear() + fake = SwaggerFakeTransport(registry=_REGISTRY) + fake.add_success_operation(binding.operation_key or "") + + with pytest.warns(DeprecationWarning): + fake.invoke_binding(binding) + + +def test_swagger_fake_transport_invokes_generated_read_call_and_validates_path() -> None: + registry = load_swagger_registry() + binding = _binding(registry, "Информацияопользователе.json GET /core/v1/accounts/{user_id}/balance") + fake = SwaggerFakeTransport(registry=registry) + fake.add_operation( + binding.operation_key or "", + {"user_id": 7, "balance": {"real": 150.0, "bonus": 20.0, "currency": "RUB"}}, + ) + + result = fake.invoke_binding(binding) + + request = fake.last() + assert request.method == "GET" + assert request.path == "/core/v1/accounts/7/balance/" + assert request.json_body is None + assert result.to_dict()["total"] == 170.0 + + +def test_swagger_fake_transport_invokes_generated_write_call_and_validates_json_body() -> None: + registry = load_swagger_registry() + binding = _binding(registry, "ИерархияАккаунтов.json POST /listItemsByEmployeeIdV1") + fake = SwaggerFakeTransport(registry=registry) + fake.add_operation( + binding.operation_key or "", + { + "items": [{"item_id": 105, "title": "Объявление", "status": "active"}], + "total": 1, + }, + ) + + result = fake.invoke_binding(binding) + + request = fake.last() + assert request.method == "POST" + assert request.path == "/listItemsByEmployeeIdV1" + assert request.headers["content-type"] == "application/json" + assert request.json_body == {"employeeId": 10, "limit": 2, "offset": 0} + assert isinstance(result, PaginatedList) + assert isinstance(result[0], EmployeeItem) + + +def test_swagger_fake_transport_validates_response_status_is_declared_in_swagger() -> None: + registry = load_swagger_registry() + fake = SwaggerFakeTransport(registry=registry) + + with pytest.raises(AssertionError, match="не описан в Swagger responses"): + fake.add_operation( + "Информацияопользователе.json GET /core/v1/accounts/self", + {}, + status_code=418, + ) + + +def test_swagger_fake_transport_maps_happy_path_response_to_typed_sdk_model() -> None: + registry = load_swagger_registry() + binding = _binding(registry, "Информацияопользователе.json GET /core/v1/accounts/self") + fake = SwaggerFakeTransport(registry=registry) + fake.add_operation( + binding.operation_key or "", + {"id": 7, "name": "Иван", "email": "user@example.test", "phone": "+7000"}, + ) + + result = fake.invoke_binding(binding) + + assert isinstance(result, AccountProfile) + assert result.user_id == 7 + assert result.name == "Иван" + + +@pytest.mark.parametrize( + ("operation_key", "status_code", "expected_error"), + [ + ( + "Информацияопользователе.json GET /core/v1/accounts/self", + 401, + AuthenticationError, + ), + ( + "Информацияопользователе.json GET /core/v1/accounts/self", + 403, + AuthorizationError, + ), + ( + "АвитоРабота.json GET /job/v1/applications/get_states", + 400, + ValidationError, + ), + ( + "АвитоРабота.json GET /job/v1/applications/get_states", + 402, + UpstreamApiError, + ), + ( + "АвитоРабота.json GET /job/v1/applications/get_states", + 404, + NotFoundError, + ), + ( + "Автостратегия.json POST /autostrategy/v1/campaign/info", + 409, + ConflictError, + ), + ( + "Краткосрочнаяаренда.json POST /realty/v1/items/intervals", + 422, + ValidationError, + ), + ( + "CallTracking[КТ].json GET /calltracking/v1/getRecordByCallId", + 425, + UpstreamApiError, + ), + ( + "АвитоРабота.json GET /job/v1/applications/get_states", + 429, + RateLimitError, + ), + ( + "Информацияопользователе.json GET /core/v1/accounts/self", + 500, + ServerError, + ), + ( + "Информацияопользователе.json GET /core/v1/accounts/self", + 503, + ServerError, + ), + ], +) +def test_swagger_fake_transport_validates_all_swagger_error_status_categories( + operation_key: str, + status_code: int, + expected_error: type[Exception], +) -> None: + registry = load_swagger_registry() + binding = _binding(registry, operation_key) + fake = SwaggerFakeTransport(registry=registry) + fake.add_operation(operation_key, error_payload(status_code), status_code=status_code) + + with pytest.raises(expected_error) as exc_info: + fake.invoke_binding(binding) + + assert exc_info.value.args[0] == f"Ошибка {status_code}" + + +def test_swagger_fake_transport_covers_all_error_statuses_declared_by_corpus() -> None: + registry = load_swagger_registry() + + assert sorted( + { + response.status_code + for operation in registry.operations + for response in operation.error_responses + if response.status_code.isdigit() + } + ) == ["400", "401", "402", "403", "404", "409", "422", "425", "429", "500", "503"] + + +def test_swagger_fake_transport_invokes_deprecated_legacy_operation_with_runtime_warning() -> None: + registry = load_swagger_registry() + binding = _binding(registry, "Автозагрузка.json GET /autoload/v1/profile") + fake = SwaggerFakeTransport(registry=registry) + fake.add_operation( + binding.operation_key or "", + {"user_id": 7, "is_enabled": True, "upload_url": "https://example.test/upload"}, + ) + + _WARNED_SYMBOLS.clear() + with pytest.warns(DeprecationWarning): + result = fake.invoke_binding(binding) + + request = fake.last() + assert request.method == "GET" + assert request.path == "/autoload/v1/profile" + assert result.to_dict()["is_enabled"] is True diff --git a/tests/core/test_configuration.py b/tests/core/test_configuration.py index 8de9590..1a6489b 100644 --- a/tests/core/test_configuration.py +++ b/tests/core/test_configuration.py @@ -189,6 +189,9 @@ def test_process_environment_overrides_dotenv_and_parses_retry_options( "AVITO_RETRY_RETRYABLE_METHODS=GET,POST,PATCH", "AVITO_RETRY_RETRY_ON_RATE_LIMIT=false", "AVITO_RETRY_MAX_RATE_LIMIT_WAIT_SECONDS=12.5", + "AVITO_RATE_LIMIT_ENABLED=true", + "AVITO_RATE_LIMIT_REQUESTS_PER_SECOND=3.5", + "AVITO_RATE_LIMIT_BURST=7", ) ), ) @@ -209,6 +212,9 @@ def test_process_environment_overrides_dotenv_and_parses_retry_options( assert settings.retry_policy.retryable_methods == ("GET", "POST", "PATCH") assert settings.retry_policy.retry_on_rate_limit is False assert settings.retry_policy.max_rate_limit_wait_seconds == 12.5 + assert settings.retry_policy.rate_limit_enabled is True + assert settings.retry_policy.rate_limit_requests_per_second == 3.5 + assert settings.retry_policy.rate_limit_burst == 7 def test_avito_settings_rejects_secret_like_user_agent_suffix() -> None: diff --git a/tests/core/test_swagger.py b/tests/core/test_swagger.py new file mode 100644 index 0000000..9bfb453 --- /dev/null +++ b/tests/core/test_swagger.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import builtins +import importlib +from collections.abc import Iterator +from contextlib import contextmanager +from typing import cast + +import pytest + +import avito.core.swagger +from avito.core import ConfigurationError, SwaggerOperationBinding, swagger_operation + + +@contextmanager +def _forbid_swagger_file_reads() -> Iterator[None]: + original_open = builtins.open + + def guarded_open(file: object, *args: object, **kwargs: object) -> object: + if "docs/avito/api" in str(file): + raise AssertionError("Swagger files must not be read on import") + return original_open(file, *args, **kwargs) + + builtins.open = guarded_open + try: + yield + finally: + builtins.open = original_open + + +def test_swagger_operation_writes_metadata_to_decorated_method() -> None: + @swagger_operation( + "get", + "/messenger/v1/accounts/{user_id}/chats/", + spec="Мессенджер.json", + operation_id="getChats", + factory="chat", + factory_args={"user_id": "path.user_id"}, + method_args={"limit": "query.limit"}, + deprecated=True, + legacy=True, + ) + def list_chats() -> str: + return "ok" + + binding = cast(SwaggerOperationBinding, list_chats.__swagger_binding__) + + assert binding == SwaggerOperationBinding( + method="GET", + path="/messenger/v1/accounts/{user_id}/chats", + spec="Мессенджер.json", + operation_id="getChats", + factory="chat", + factory_args={"user_id": "path.user_id"}, + method_args={"limit": "query.limit"}, + deprecated=True, + legacy=True, + ) + + +def test_swagger_operation_does_not_change_decorated_method_behavior() -> None: + @swagger_operation("POST", "/items/{item_id}") + def update_item(item_id: int, *, title: str) -> tuple[int, str]: + return item_id, title + + assert update_item(42, title="listing") == (42, "listing") + + +def test_swagger_operation_stores_immutable_mapping_copies() -> None: + factory_args = {"user_id": "path.user_id"} + method_args = {"limit": "query.limit"} + + @swagger_operation( + "GET", + "/items", + factory_args=factory_args, + method_args=method_args, + ) + def list_items() -> str: + return "ok" + + factory_args["user_id"] = "query.user_id" + method_args["limit"] = "constant.limit" + binding = cast(SwaggerOperationBinding, list_items.__swagger_binding__) + + assert binding.factory_args["user_id"] == "path.user_id" + assert binding.method_args["limit"] == "query.limit" + with pytest.raises(TypeError): + cast(dict[str, str], binding.factory_args)["extra"] = "query.extra" + with pytest.raises(TypeError): + cast(dict[str, str], binding.method_args)["extra"] = "query.extra" + + +def test_swagger_operation_rejects_forbidden_kwargs_by_signature() -> None: + with pytest.raises(TypeError): + swagger_operation( + "GET", + "/items", + response_model="Forbidden", + ) + + +def test_swagger_operation_rejects_stacked_bindings() -> None: + with pytest.raises(ConfigurationError): + + @swagger_operation("GET", "/items") + @swagger_operation("POST", "/items") + def sync_items() -> str: + return "ok" + + +def test_swagger_module_does_not_read_swagger_files_on_import() -> None: + with _forbid_swagger_file_reads(): + importlib.reload(avito.core.swagger) diff --git a/tests/core/test_swagger_discovery.py b/tests/core/test_swagger_discovery.py new file mode 100644 index 0000000..a85bd63 --- /dev/null +++ b/tests/core/test_swagger_discovery.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager + +from avito.accounts.domain import Account +from avito.client import AvitoClient +from avito.config import AvitoSettings +from avito.core.swagger import SwaggerOperationBinding +from avito.core.swagger_discovery import discover_swagger_bindings +from avito.core.swagger_registry import load_swagger_registry + + +@contextmanager +def _temporary_account_binding( + binding: SwaggerOperationBinding, +) -> Iterator[None]: + original_binding = getattr(Account.get_self, "__swagger_binding__", None) + original_domain = getattr(Account, "__swagger_domain__", None) + original_spec = getattr(Account, "__swagger_spec__", None) + original_factory = getattr(Account, "__sdk_factory__", None) + original_factory_args = getattr(Account, "__sdk_factory_args__", None) + Account.get_self.__swagger_binding__ = binding # type: ignore[attr-defined] + Account.__swagger_domain__ = "accounts" + Account.__swagger_spec__ = "Информацияопользователе.json" + Account.__sdk_factory__ = "account" + Account.__sdk_factory_args__ = {"user_id": "constant.user_id"} + try: + yield + finally: + if original_binding is None: + delattr(Account.get_self, "__swagger_binding__") + else: + Account.get_self.__swagger_binding__ = original_binding # type: ignore[attr-defined] + _restore_class_attribute(Account, "__swagger_domain__", original_domain) + _restore_class_attribute(Account, "__swagger_spec__", original_spec) + _restore_class_attribute(Account, "__sdk_factory__", original_factory) + _restore_class_attribute(Account, "__sdk_factory_args__", original_factory_args) + + +def _restore_class_attribute(cls: type[object], name: str, value: object) -> None: + if value is None: + if hasattr(cls, name): + delattr(cls, name) + return + setattr(cls, name, value) + + +def test_discover_swagger_bindings_returns_empty_result_for_unmarked_sdk() -> None: + discovery = discover_swagger_bindings() + + assert len(discovery.bindings) == 204 + assert len(discovery.canonical_map) == 204 + + +def test_discover_swagger_bindings_does_not_create_client_or_read_env( + monkeypatch, +) -> None: + def fail_init(self: AvitoClient, *args: object, **kwargs: object) -> None: + raise AssertionError("AvitoClient must not be created during discovery") + + def fail_from_env() -> AvitoSettings: + raise AssertionError("Environment settings must not be read during discovery") + + monkeypatch.setattr(AvitoClient, "__init__", fail_init) + monkeypatch.setattr(AvitoSettings, "from_env", fail_from_env) + + discovery = discover_swagger_bindings() + + assert len(discovery.bindings) == 204 + + +def test_discover_swagger_bindings_uses_class_level_defaults() -> None: + binding = SwaggerOperationBinding( + method="get", + path="/core/v1/accounts/self", + operation_id="getUserInfoSelf", + ) + + with _temporary_account_binding(binding): + discovery = discover_swagger_bindings() + + discovered = _find_binding(discovery.bindings, "avito.accounts.domain.Account.get_self") + assert discovered.sdk_method == "avito.accounts.domain.Account.get_self" + assert discovered.domain == "accounts" + assert discovered.operation_key == "Информацияопользователе.json GET /core/v1/accounts/self" + assert discovered.factory == "account" + assert dict(discovered.factory_args) == {"user_id": "constant.user_id"} + assert discovered.operation_id == "getUserInfoSelf" + + +def test_discover_swagger_bindings_auto_resolves_spec_from_registry() -> None: + binding = SwaggerOperationBinding( + method="GET", + path="/core/v1/accounts/self", + operation_id="getUserInfoSelf", + ) + registry = load_swagger_registry() + + with _temporary_account_binding(binding): + delattr(Account, "__swagger_spec__") + discovery = discover_swagger_bindings(registry=registry) + + discovered = _find_binding(discovery.bindings, "avito.accounts.domain.Account.get_self") + assert discovered.spec == "Информацияопользователе.json" + assert discovered.operation_key == "Информацияопользователе.json GET /core/v1/accounts/self" + assert discovery.canonical_map[discovered.operation_key] == discovered + + +def test_discover_swagger_bindings_reports_legacy_stacked_metadata() -> None: + original_binding = getattr(Account.get_self, "__swagger_binding__", None) + original_bindings = getattr(Account.get_self, "__swagger_bindings__", None) + binding = SwaggerOperationBinding(method="GET", path="/core/v1/accounts/self") + Account.get_self.__swagger_binding__ = binding # type: ignore[attr-defined] + Account.get_self.__swagger_bindings__ = (binding,) # type: ignore[attr-defined] + try: + discovery = discover_swagger_bindings() + finally: + if original_binding is None: + delattr(Account.get_self, "__swagger_binding__") + else: + Account.get_self.__swagger_binding__ = original_binding # type: ignore[attr-defined] + if original_bindings is None: + delattr(Account.get_self, "__swagger_bindings__") + else: + Account.get_self.__swagger_bindings__ = original_bindings # type: ignore[attr-defined] + + assert "avito.accounts.domain.Account.get_self" in discovery.legacy_binding_methods + + +def _find_binding(bindings: object, sdk_method: str) -> object: + for binding in bindings: + if binding.sdk_method == sdk_method: + return binding + raise AssertionError(f"Binding not found: {sdk_method}") diff --git a/tests/core/test_swagger_factory_map.py b/tests/core/test_swagger_factory_map.py new file mode 100644 index 0000000..c20974c --- /dev/null +++ b/tests/core/test_swagger_factory_map.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from avito.client import AvitoClient +from avito.config import AvitoSettings +from avito.core.swagger_discovery import discover_swagger_bindings +from avito.core.swagger_factory_map import build_factory_domain_mapping_report +from avito.core.swagger_registry import load_swagger_registry +from avito.core.swagger_report import build_swagger_binding_report + + +def test_build_factory_domain_mapping_report_does_not_create_client_or_read_env( + monkeypatch, +) -> None: + def fail_init(self: AvitoClient, *args: object, **kwargs: object) -> None: + raise AssertionError("AvitoClient must not be created during factory mapping") + + def fail_from_env() -> AvitoSettings: + raise AssertionError("Environment settings must not be read during factory mapping") + + monkeypatch.setattr(AvitoClient, "__init__", fail_init) + monkeypatch.setattr(AvitoSettings, "from_env", fail_from_env) + + report = build_factory_domain_mapping_report() + + assert report.factories + + +def test_build_factory_domain_mapping_report_maps_factories_to_domain_classes() -> None: + report = build_factory_domain_mapping_report() + factories = {mapping.factory: mapping for mapping in report.factories} + + assert factories["account"].domain_class == "Account" + assert factories["account"].module == "avito.accounts.domain" + assert factories["account"].factory_args == ("user_id",) + assert factories["account"].spec_candidates == ("Информацияопользователе.json",) + assert factories["chat"].domain_class == "Chat" + assert factories["chat"].factory_args == ("chat_id", "user_id") + assert factories["chat"].spec_candidates == ("Мессенджер.json",) + assert factories["promotion_order"].spec_candidates == ("Продвижение.json",) + + +def test_build_factory_domain_mapping_report_identifies_summary_and_helper_methods() -> None: + report = build_factory_domain_mapping_report() + helper_methods = {helper.method: helper for helper in report.helper_methods} + + assert helper_methods["account_health"].reason == ( + "summary/helper method; no direct upstream Swagger operation" + ) + assert helper_methods["business_summary"].reason == ( + "summary/helper method; no direct upstream Swagger operation" + ) + assert helper_methods["capabilities"].reason == ( + "summary/helper method; no direct upstream Swagger operation" + ) + assert "account" not in helper_methods + + +def test_swagger_binding_report_includes_factory_mapping_as_non_authoritative_section() -> None: + registry = load_swagger_registry() + discovery = discover_swagger_bindings(registry=registry) + factory_mapping = build_factory_domain_mapping_report() + + report = build_swagger_binding_report( + registry, + discovery, + factory_mapping=factory_mapping, + ).to_dict() + + assert report["summary"]["operations_total"] == 204 + assert report["factory_mapping"] == factory_mapping.to_dict() diff --git a/tests/core/test_swagger_linter.py b/tests/core/test_swagger_linter.py new file mode 100644 index 0000000..bcbd084 --- /dev/null +++ b/tests/core/test_swagger_linter.py @@ -0,0 +1,543 @@ +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager + +from avito.accounts.domain import Account, AccountHierarchy +from avito.ads.domain import AutoloadArchive +from avito.core.swagger import SwaggerOperationBinding +from avito.core.swagger_discovery import ( + DiscoveredSwaggerBinding, + SwaggerBindingDiscovery, + discover_swagger_bindings, +) +from avito.core.swagger_linter import lint_swagger_bindings +from avito.core.swagger_registry import ( + SwaggerOperation, + SwaggerRegistry, + SwaggerRequestBody, + SwaggerResponse, + SwaggerSpec, + load_swagger_registry, +) + + +@contextmanager +def _temporary_binding( + cls: type[object], + method_name: str, + binding: SwaggerOperationBinding, + *, + spec: str | None = "Информацияопользователе.json", + factory: str | None = "account", +) -> Iterator[None]: + method = getattr(cls, method_name) + original_binding = getattr(method, "__swagger_binding__", None) + original_domain = getattr(cls, "__swagger_domain__", None) + original_spec = getattr(cls, "__swagger_spec__", None) + original_factory = getattr(cls, "__sdk_factory__", None) + method.__swagger_binding__ = binding + cls.__swagger_domain__ = "accounts" + _set_optional_class_attribute(cls, "__swagger_spec__", spec) + _set_optional_class_attribute(cls, "__sdk_factory__", factory) + try: + yield + finally: + if original_binding is None: + delattr(method, "__swagger_binding__") + else: + method.__swagger_binding__ = original_binding + _restore_class_attribute(cls, "__swagger_domain__", original_domain) + _restore_class_attribute(cls, "__swagger_spec__", original_spec) + _restore_class_attribute(cls, "__sdk_factory__", original_factory) + + +@contextmanager +def _temporary_account_bindings( + bindings: dict[str, SwaggerOperationBinding], +) -> Iterator[None]: + original_bindings = { + method_name: getattr(getattr(Account, method_name), "__swagger_binding__", None) + for method_name in bindings + } + original_domain = getattr(Account, "__swagger_domain__", None) + original_spec = getattr(Account, "__swagger_spec__", None) + original_factory = getattr(Account, "__sdk_factory__", None) + Account.__swagger_domain__ = "accounts" + Account.__swagger_spec__ = "Информацияопользователе.json" + Account.__sdk_factory__ = "account" + for method_name, binding in bindings.items(): + getattr(Account, method_name).__swagger_binding__ = binding + try: + yield + finally: + for method_name, original_binding in original_bindings.items(): + method = getattr(Account, method_name) + if original_binding is None: + delattr(method, "__swagger_binding__") + else: + method.__swagger_binding__ = original_binding + _restore_class_attribute(Account, "__swagger_domain__", original_domain) + _restore_class_attribute(Account, "__swagger_spec__", original_spec) + _restore_class_attribute(Account, "__sdk_factory__", original_factory) + + +def _set_optional_class_attribute(cls: type[object], name: str, value: str | None) -> None: + if value is None: + if hasattr(cls, name): + delattr(cls, name) + return + setattr(cls, name, value) + + +def _restore_class_attribute(cls: type[object], name: str, value: object) -> None: + if value is None: + if hasattr(cls, name): + delattr(cls, name) + return + setattr(cls, name, value) + + +def test_lint_swagger_bindings_allows_empty_discovery_in_non_strict_mode() -> None: + registry = load_swagger_registry() + discovery = discover_swagger_bindings(registry=registry) + + errors = lint_swagger_bindings(registry, discovery) + + assert errors == () + + +def test_lint_swagger_bindings_rejects_unknown_spec() -> None: + registry = load_swagger_registry() + binding = SwaggerOperationBinding(method="GET", path="/core/v1/accounts/self") + + with _temporary_binding(Account, "get_self", binding, spec="НетТакогоSpec.json"): + discovery = discover_swagger_bindings(registry=registry) + + errors = lint_swagger_bindings(registry, discovery) + + assert [error.code for error in errors] == ["SWAGGER_BINDING_SPEC_NOT_FOUND"] + + +def test_lint_swagger_bindings_rejects_missing_operation() -> None: + registry = load_swagger_registry() + binding = SwaggerOperationBinding(method="GET", path="/missing") + + with _temporary_binding(Account, "get_self", binding): + discovery = discover_swagger_bindings(registry=registry) + + errors = lint_swagger_bindings(registry, discovery) + + assert [error.code for error in errors] == ["SWAGGER_BINDING_NOT_FOUND"] + + +def test_lint_swagger_bindings_rejects_duplicate_operation_bindings() -> None: + registry = load_swagger_registry() + binding = SwaggerOperationBinding(method="GET", path="/core/v1/accounts/self") + + with _temporary_account_bindings({"get_self": binding, "get_balance": binding}): + discovery = discover_swagger_bindings(registry=registry) + + errors = lint_swagger_bindings(registry, discovery) + + assert [error.code for error in errors] == [ + "SWAGGER_BINDING_DUPLICATE", + "SWAGGER_BINDING_DUPLICATE", + ] + + +def test_lint_swagger_bindings_rejects_one_sdk_method_bound_to_multiple_operations() -> None: + registry = load_swagger_registry() + discovery = discover_swagger_bindings(registry=registry) + first = discovery.bindings[0] + duplicate_sdk_method = type(first)( + module=first.module, + class_name=first.class_name, + method_name=first.method_name, + domain=first.domain, + operation_key="Информацияопользователе.json GET /core/v1/accounts/{user_id}/balance", + spec="Информацияопользователе.json", + method="GET", + path="/core/v1/accounts/{user_id}/balance", + operation_id="getUserBalance", + factory=first.factory, + factory_args=first.factory_args, + method_args=first.method_args, + deprecated=first.deprecated, + legacy=first.legacy, + ) + patched_discovery = type(discovery)( + bindings=(first, duplicate_sdk_method), + legacy_binding_methods=(), + ) + + errors = lint_swagger_bindings(registry, patched_discovery) + + assert _codes_for(errors, first.sdk_method, exclude={"SWAGGER_BINDING_DUPLICATE"}) == [ + "SWAGGER_BINDING_METHOD_MULTIPLE", + "SWAGGER_BINDING_METHOD_MULTIPLE", + ] + + +def test_lint_swagger_bindings_rejects_legacy_stacked_metadata() -> None: + registry = load_swagger_registry() + discovery = type(discover_swagger_bindings(registry=registry))( + bindings=(), + legacy_binding_methods=("avito.accounts.domain.Account.get_self",), + ) + + errors = lint_swagger_bindings(registry, discovery) + + assert _codes_for(errors, "avito.accounts.domain.Account.get_self") == [ + "SWAGGER_BINDING_METHOD_MULTIPLE" + ] + + +def test_lint_swagger_bindings_rejects_metadata_mismatches() -> None: + registry = load_swagger_registry() + binding = SwaggerOperationBinding( + method="GET", + path="/core/v1/accounts/self", + operation_id="wrongOperationId", + deprecated=True, + legacy=True, + ) + + with _temporary_binding(Account, "get_self", binding): + discovery = discover_swagger_bindings(registry=registry) + + errors = lint_swagger_bindings(registry, discovery) + + assert [error.code for error in errors] == [ + "SWAGGER_BINDING_OPERATION_ID_MISMATCH", + "SWAGGER_BINDING_DEPRECATED_MISMATCH", + "SWAGGER_BINDING_LEGACY_MISMATCH", + ] + + +def test_lint_swagger_bindings_rejects_missing_and_unknown_factory() -> None: + registry = load_swagger_registry() + binding = SwaggerOperationBinding(method="GET", path="/core/v1/accounts/self") + + with _temporary_binding(Account, "get_self", binding, factory=None): + discovery = discover_swagger_bindings(registry=registry) + missing_errors = lint_swagger_bindings(registry, discovery) + + with _temporary_binding(Account, "get_self", binding, factory="missing_factory"): + discovery = discover_swagger_bindings(registry=registry) + unknown_errors = lint_swagger_bindings(registry, discovery) + + assert _codes_for(missing_errors, "avito.accounts.domain.Account.get_self") == [ + "SWAGGER_BINDING_FACTORY_MISSING" + ] + assert _codes_for(unknown_errors, "avito.accounts.domain.Account.get_self") == [ + "SWAGGER_BINDING_FACTORY_NOT_FOUND" + ] + + +def test_lint_swagger_bindings_validates_factory_and_method_signatures() -> None: + registry = load_swagger_registry() + binding = SwaggerOperationBinding( + method="POST", + path="/linkItemsV1", + spec="ИерархияАккаунтов.json", + factory_args={"unknown": "constant.value"}, + method_args={"employee_id": "body.employee_id", "unknown": "constant.value"}, + ) + + with _temporary_binding( + AccountHierarchy, + "link_items", + binding, + spec=None, + factory="account_hierarchy", + ): + discovery = discover_swagger_bindings(registry=registry) + + errors = lint_swagger_bindings(registry, discovery) + + assert _codes_for( + errors, + "avito.accounts.domain.AccountHierarchy.link_items", + exclude={"SWAGGER_BINDING_DUPLICATE"}, + ) == [ + "SWAGGER_BINDING_FACTORY_ARG_UNKNOWN", + "SWAGGER_BINDING_METHOD_ARG_UNKNOWN", + "SWAGGER_BINDING_METHOD_ARG_REQUIRED", + ] + + +def test_lint_swagger_bindings_validates_parameter_expressions_against_swagger() -> None: + registry = load_swagger_registry() + cases = { + "path": ( + SwaggerOperationBinding( + method="GET", + path="/core/v1/accounts/{user_id}/balance", + method_args={"user_id": "path.missing"}, + ), + "SWAGGER_BINDING_PATH_PARAMETER_NOT_FOUND", + ), + "query": ( + SwaggerOperationBinding( + method="GET", + path="/core/v1/accounts/{user_id}/balance", + method_args={"user_id": "query.missing"}, + ), + "SWAGGER_BINDING_QUERY_PARAMETER_NOT_FOUND", + ), + "header": ( + SwaggerOperationBinding( + method="GET", + path="/core/v1/accounts/{user_id}/balance", + method_args={"user_id": "header.missing"}, + ), + "SWAGGER_BINDING_HEADER_PARAMETER_NOT_FOUND", + ), + } + + for case_name, (binding, expected_code) in cases.items(): + with _temporary_binding(Account, "get_balance", binding): + discovery = discover_swagger_bindings(registry=registry) + + errors = lint_swagger_bindings(registry, discovery) + + assert _codes_for( + errors, + "avito.accounts.domain.Account.get_balance", + exclude={"SWAGGER_BINDING_DUPLICATE"}, + ) == [expected_code], case_name + + +def test_lint_swagger_bindings_validates_body_and_constant_expressions() -> None: + registry = load_swagger_registry() + cases = { + "body": ( + SwaggerOperationBinding( + method="GET", + path="/core/v1/accounts/{user_id}/balance", + method_args={"user_id": "body.user_id"}, + ), + "SWAGGER_BINDING_BODY_MISSING", + ), + "constant": ( + SwaggerOperationBinding( + method="GET", + path="/core/v1/accounts/{user_id}/balance", + method_args={"user_id": "constant.missing"}, + ), + "SWAGGER_BINDING_CONSTANT_NOT_FOUND", + ), + "unknown_prefix": ( + SwaggerOperationBinding( + method="GET", + path="/core/v1/accounts/{user_id}/balance", + method_args={"user_id": "cookie.user_id"}, + ), + "SWAGGER_BINDING_EXPRESSION_UNKNOWN", + ), + } + + for case_name, (binding, expected_code) in cases.items(): + with _temporary_binding(Account, "get_balance", binding): + discovery = discover_swagger_bindings(registry=registry) + + errors = lint_swagger_bindings(registry, discovery) + + assert _codes_for( + errors, + "avito.accounts.domain.Account.get_balance", + exclude={"SWAGGER_BINDING_DUPLICATE"}, + ) == [expected_code], case_name + + +def test_lint_swagger_bindings_allows_valid_body_and_constant_expressions() -> None: + registry = load_swagger_registry() + binding = SwaggerOperationBinding( + method="POST", + path="/core/v1/accounts/operations_history", + method_args={"date_from": "body.date_time_from"}, + factory_args={"user_id": "constant.user_id"}, + ) + + with _temporary_binding(Account, "get_operations_history", binding): + discovery = discover_swagger_bindings(registry=registry) + + errors = lint_swagger_bindings(registry, discovery) + + assert _codes_for( + errors, + "avito.accounts.domain.Account.get_operations_history", + exclude={"SWAGGER_BINDING_DUPLICATE"}, + ) == [] + + +def test_lint_swagger_bindings_rejects_unknown_body_field() -> None: + registry, discovery = _single_body_field_discovery( + expression="body.missing", + request_body=SwaggerRequestBody( + required=True, + content_types=("application/json",), + field_names=("employeeId", "employee_id"), + schema_extracted=True, + ), + ) + + errors = lint_swagger_bindings(registry, discovery) + + assert _codes_for(errors, "avito.accounts.domain.AccountHierarchy.link_items") == [ + "SWAGGER_BINDING_BODY_FIELD_NOT_FOUND" + ] + + +def test_lint_swagger_bindings_rejects_unsupported_body_schema_for_field_expression() -> None: + registry, discovery = _single_body_field_discovery( + expression="body.employee_id", + item_ids_expression="body", + request_body=SwaggerRequestBody( + required=True, + content_types=("application/json",), + field_names=(), + schema_extracted=False, + ), + ) + + errors = lint_swagger_bindings(registry, discovery) + + assert _codes_for(errors, "avito.accounts.domain.AccountHierarchy.link_items") == [ + "SWAGGER_BINDING_BODY_SCHEMA_UNSUPPORTED" + ] + + +def test_lint_swagger_bindings_allows_whole_body_expression_with_unsupported_schema() -> None: + registry, discovery = _single_body_field_discovery( + expression="body", + item_ids_expression="body", + request_body=SwaggerRequestBody( + required=True, + content_types=("application/json",), + field_names=(), + schema_extracted=False, + ), + ) + + errors = lint_swagger_bindings(registry, discovery) + + assert _codes_for(errors, "avito.accounts.domain.AccountHierarchy.link_items") == [] + + +def test_lint_swagger_bindings_requires_legacy_for_deprecated_operation() -> None: + registry = load_swagger_registry() + binding = SwaggerOperationBinding( + method="GET", + path="/autoload/v1/profile", + spec="Автозагрузка.json", + operation_id="getProfile", + factory="autoload_archive", + deprecated=True, + ) + + with _temporary_binding( + AutoloadArchive, + "get_profile", + binding, + spec=None, + factory=None, + ): + discovery = discover_swagger_bindings(registry=registry) + + errors = lint_swagger_bindings(registry, discovery) + + assert _codes_for(errors, "avito.ads.domain.AutoloadArchive.get_profile") == [ + "SWAGGER_BINDING_LEGACY_REQUIRED" + ] + + +def test_lint_swagger_bindings_requires_runtime_warning_for_deprecated_operation() -> None: + registry = load_swagger_registry() + binding = SwaggerOperationBinding( + method="GET", + path="/autoload/v1/profile", + spec="Автозагрузка.json", + operation_id="getProfile", + factory="account", + deprecated=True, + legacy=True, + ) + + with _temporary_binding( + Account, + "get_self", + binding, + spec=None, + factory=None, + ): + discovery = discover_swagger_bindings(registry=registry) + + errors = lint_swagger_bindings(registry, discovery) + + assert _codes_for( + errors, + "avito.accounts.domain.Account.get_self", + exclude={"SWAGGER_BINDING_DUPLICATE"}, + ) == ["SWAGGER_BINDING_DEPRECATION_WARNING_MISSING"] + + +def _codes_for( + errors: object, + sdk_method: str, + *, + exclude: set[str] | None = None, +) -> list[str]: + excluded = exclude or set() + return [ + error.code + for error in errors + if error.sdk_method == sdk_method and error.code not in excluded + ] + + +def _single_body_field_discovery( + *, + expression: str, + item_ids_expression: str = "body.employee_id", + request_body: SwaggerRequestBody, +) -> tuple[SwaggerRegistry, SwaggerBindingDiscovery]: + operation = SwaggerOperation( + spec="ИерархияАккаунтов.json", + method="POST", + path="/linkItemsV1", + operation_id="linkItemsV1", + deprecated=False, + parameters=(), + request_body=request_body, + responses=(SwaggerResponse(status_code="204", content_types=()),), + ) + registry = SwaggerRegistry( + specs=( + SwaggerSpec( + name="ИерархияАккаунтов.json", + path=load_swagger_registry().specs[0].path, + operations=(operation,), + ), + ) + ) + discovery = SwaggerBindingDiscovery( + bindings=( + DiscoveredSwaggerBinding( + module="avito.accounts.domain", + class_name="AccountHierarchy", + method_name="link_items", + domain="accounts", + operation_key=operation.key, + spec=operation.spec, + method=operation.method, + path=operation.path, + operation_id=operation.operation_id, + factory="account_hierarchy", + factory_args={}, + method_args={"employee_id": expression, "item_ids": item_ids_expression}, + ), + ) + ) + return registry, discovery diff --git a/tests/core/test_swagger_registry.py b/tests/core/test_swagger_registry.py new file mode 100644 index 0000000..9ff68f4 --- /dev/null +++ b/tests/core/test_swagger_registry.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from avito.core.swagger_registry import ( + SwaggerRegistryError, + load_swagger_registry, + normalize_swagger_path, +) + + +def test_load_swagger_registry_extracts_current_corpus_counts() -> None: + registry = load_swagger_registry() + + assert len(registry.specs) == 23 + assert len(registry.operations) == 204 + assert len(registry.deprecated_operations) == 7 + assert registry.errors == () + + +def test_load_swagger_registry_extracts_operation_level_deprecated_policy_set() -> None: + registry = load_swagger_registry() + + assert [operation.key for operation in registry.deprecated_operations] == [ + "CPAАвито.json GET /cpa/v1/call/{call_id}", + "CPAАвито.json POST /cpa/v2/balanceInfo", + "CPAАвито.json POST /cpa/v2/callById", + "Автозагрузка.json GET /autoload/v1/profile", + "Автозагрузка.json POST /autoload/v1/profile", + "Автозагрузка.json GET /autoload/v2/reports/last_completed_report", + "Автозагрузка.json GET /autoload/v2/reports/{report_id}", + ] + + +def test_load_swagger_registry_extracts_operation_contract_metadata() -> None: + registry = load_swagger_registry() + + operation = next( + operation + for operation in registry.operations + if operation.key + == "Мессенджер.json POST /messenger/v1/accounts/{user_id}/chats/{chat_id}/messages" + ) + + assert operation.operation_id == "postSendMessage" + assert operation.deprecated is False + assert [parameter.name for parameter in operation.path_parameters] == ["user_id", "chat_id"] + assert [parameter.name for parameter in operation.header_parameters] == ["Authorization"] + assert operation.request_body is not None + assert operation.request_body.content_types == ("application/json",) + assert operation.request_body.schema_extracted is True + assert "message" in operation.request_body.field_names + assert [(response.status_code, response.content_types) for response in operation.responses] == [ + ("200", ("application/json",)), + ] + + +def test_load_swagger_registry_extracts_inline_request_body_properties(tmp_path: Path) -> None: + spec_path = tmp_path / "Inline.json" + spec_path.write_text( + json.dumps( + { + "openapi": "3.0.0", + "paths": { + "/items": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"itemId": {"type": "integer"}}, + } + } + } + }, + "responses": {"200": {"description": "ok"}}, + } + } + }, + } + ), + encoding="utf-8", + ) + + operation = load_swagger_registry(tmp_path).operations[0] + + assert operation.request_body is not None + assert operation.request_body.schema_extracted is True + assert operation.request_body.field_names == ("itemId", "item_id") + + +def test_load_swagger_registry_extracts_ref_request_body_properties(tmp_path: Path) -> None: + spec_path = tmp_path / "Ref.json" + spec_path.write_text( + json.dumps( + { + "openapi": "3.0.0", + "components": { + "schemas": { + "Payload": { + "type": "object", + "properties": {"orderIDs": {"type": "array"}}, + } + } + }, + "paths": { + "/labels": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Payload"} + } + } + }, + "responses": {"200": {"description": "ok"}}, + } + } + }, + } + ), + encoding="utf-8", + ) + + operation = load_swagger_registry(tmp_path).operations[0] + + assert operation.request_body is not None + assert operation.request_body.field_names == ("orderIDs", "order_ids") + + +def test_load_swagger_registry_records_unsupported_request_body_schema(tmp_path: Path) -> None: + spec_path = tmp_path / "Unsupported.json" + spec_path.write_text( + json.dumps( + { + "openapi": "3.0.0", + "paths": { + "/items": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": {"type": "array", "items": {"type": "string"}} + } + } + }, + "responses": {"200": {"description": "ok"}}, + } + } + }, + } + ), + encoding="utf-8", + ) + + operation = load_swagger_registry(tmp_path).operations[0] + + assert operation.request_body is not None + assert operation.request_body.schema_extracted is False + assert operation.request_body.field_names == () + + +def test_normalize_swagger_path_removes_trailing_slash() -> None: + assert normalize_swagger_path("/core/v1/accounts/{user_id}/balance/") == ( + "/core/v1/accounts/{user_id}/balance" + ) + + +def test_load_swagger_registry_normalizes_camel_case_path_parameter_aliases( + tmp_path: Path, +) -> None: + spec_path = tmp_path / "Alias.json" + spec_path.write_text( + json.dumps( + { + "openapi": "3.0.0", + "paths": { + "/items/{itemId}": { + "get": { + "operationId": "getItem", + "parameters": [ + { + "name": "item_id", + "in": "path", + "required": True, + } + ], + "responses": {"200": {"description": "ok"}}, + } + } + }, + } + ), + encoding="utf-8", + ) + + registry = load_swagger_registry(tmp_path, strict=True) + + assert registry.operations[0].key == "Alias.json GET /items/{item_id}" + + +def test_load_swagger_registry_rejects_path_parameter_mismatch(tmp_path: Path) -> None: + spec_path = tmp_path / "Broken.json" + spec_path.write_text( + json.dumps( + { + "openapi": "3.0.0", + "paths": { + "/items/{item_id}": { + "get": { + "operationId": "getItem", + "parameters": [ + { + "name": "wrong_id", + "in": "path", + "required": True, + } + ], + "responses": {"200": {"description": "ok"}}, + } + } + }, + } + ), + encoding="utf-8", + ) + + with pytest.raises(SwaggerRegistryError, match="path parameters"): + load_swagger_registry(tmp_path, strict=True) + + +def test_load_swagger_registry_records_path_parameter_mismatch_in_non_strict_mode( + tmp_path: Path, +) -> None: + spec_path = tmp_path / "Broken.json" + spec_path.write_text( + json.dumps( + { + "openapi": "3.0.0", + "paths": { + "/items/{item_id}": { + "get": { + "operationId": "getItem", + "parameters": [ + { + "name": "wrong_id", + "in": "path", + "required": True, + } + ], + "responses": {"200": {"description": "ok"}}, + } + } + }, + } + ), + encoding="utf-8", + ) + + registry = load_swagger_registry(tmp_path) + + assert len(registry.operations) == 1 + assert len(registry.errors) == 1 + assert registry.errors[0].code == "SWAGGER_PATH_PARAMETER_MISMATCH" diff --git a/tests/core/test_swagger_report.py b/tests/core/test_swagger_report.py new file mode 100644 index 0000000..cc2d27f --- /dev/null +++ b/tests/core/test_swagger_report.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager + +from avito.accounts.domain import Account +from avito.core.swagger import SwaggerOperationBinding +from avito.core.swagger_discovery import discover_swagger_bindings +from avito.core.swagger_registry import load_swagger_registry +from avito.core.swagger_report import build_swagger_binding_report + + +@contextmanager +def _temporary_account_bindings( + bindings: dict[str, SwaggerOperationBinding], + *, + spec: str | None = "Информацияопользователе.json", +) -> Iterator[None]: + original_bindings = { + method_name: getattr(getattr(Account, method_name), "__swagger_binding__", None) + for method_name in bindings + } + original_domain = getattr(Account, "__swagger_domain__", None) + original_spec = getattr(Account, "__swagger_spec__", None) + original_factory = getattr(Account, "__sdk_factory__", None) + Account.__swagger_domain__ = "accounts" + if spec is None and hasattr(Account, "__swagger_spec__"): + delattr(Account, "__swagger_spec__") + elif spec is not None: + Account.__swagger_spec__ = spec + Account.__sdk_factory__ = "account" + for method_name, binding in bindings.items(): + getattr(Account, method_name).__swagger_binding__ = binding + try: + yield + finally: + for method_name, original_binding in original_bindings.items(): + method = getattr(Account, method_name) + if original_binding is None: + delattr(method, "__swagger_binding__") + else: + method.__swagger_binding__ = original_binding + _restore_class_attribute(Account, "__swagger_domain__", original_domain) + _restore_class_attribute(Account, "__swagger_spec__", original_spec) + _restore_class_attribute(Account, "__sdk_factory__", original_factory) + + +def _restore_class_attribute(cls: type[object], name: str, value: object) -> None: + if value is None: + if hasattr(cls, name): + delattr(cls, name) + return + setattr(cls, name, value) + + +def test_build_swagger_binding_report_marks_current_corpus_as_complete() -> None: + registry = load_swagger_registry() + discovery = discover_swagger_bindings(registry=registry) + + report = build_swagger_binding_report(registry, discovery).to_dict() + + assert report["summary"] == { + "specs": 23, + "operations_total": 204, + "deprecated_operations": 7, + "bound": 204, + "unbound": 0, + "duplicate": 0, + "ambiguous": 0, + } + operations = report["operations"] + assert isinstance(operations, list) + assert operations[0].keys() >= { + "spec", + "method", + "path", + "operation_id", + "deprecated", + "status", + "binding", + } + assert {operation["status"] for operation in operations} == {"bound"} + + +def test_build_swagger_binding_report_marks_bound_operation() -> None: + registry = load_swagger_registry() + binding = SwaggerOperationBinding( + method="GET", + path="/core/v1/accounts/self", + operation_id="getUserInfoSelf", + ) + + with _temporary_account_bindings({"get_self": binding}): + discovery = discover_swagger_bindings(registry=registry) + + report = build_swagger_binding_report(registry, discovery).to_dict() + + assert report["summary"]["bound"] == 204 + operation = _find_operation( + report, + "Информацияопользователе.json GET /core/v1/accounts/self", + ) + assert operation["status"] == "bound" + assert operation["binding"]["sdk_method"] == "avito.accounts.domain.Account.get_self" + + +def test_build_swagger_binding_report_marks_duplicate_bindings() -> None: + registry = load_swagger_registry() + binding = SwaggerOperationBinding( + method="GET", + path="/core/v1/accounts/self", + operation_id="getUserInfoSelf", + ) + + with _temporary_account_bindings({"get_self": binding, "get_balance": binding}): + discovery = discover_swagger_bindings(registry=registry) + + report = build_swagger_binding_report(registry, discovery).to_dict() + + assert report["summary"]["duplicate"] == 1 + operation = _find_operation( + report, + "Информацияопользователе.json GET /core/v1/accounts/self", + ) + assert operation["status"] == "duplicate" + assert [binding["sdk_method"] for binding in operation["binding"]] == [ + "avito.accounts.domain.Account.get_balance", + "avito.accounts.domain.Account.get_self", + ] + + +def test_build_swagger_binding_report_marks_ambiguous_binding_without_operation_key() -> None: + registry = load_swagger_registry() + binding = SwaggerOperationBinding(method="POST", path="/token") + + with _temporary_account_bindings({"get_self": binding}, spec=None): + discovery = discover_swagger_bindings(registry=registry) + + report = build_swagger_binding_report(registry, discovery).to_dict() + + assert report["summary"]["ambiguous"] == 1 + binding = _find_binding(report, "avito.accounts.domain.Account.get_self") + assert binding["status"] == "ambiguous" + assert binding["operation_key"] is None + + +def _find_operation(report: dict[str, object], operation_key: str) -> dict[str, object]: + operations = report["operations"] + assert isinstance(operations, list) + for operation in operations: + key = f"{operation['spec']} {operation['method']} {operation['path']}" + if key == operation_key: + return operation + raise AssertionError(f"Operation not found: {operation_key}") + + +def _find_binding(report: dict[str, object], sdk_method: str) -> dict[str, object]: + bindings = report["bindings"] + assert isinstance(bindings, list) + for binding in bindings: + if binding["sdk_method"] == sdk_method: + return binding + raise AssertionError(f"Binding not found: {sdk_method}") diff --git a/tests/core/test_transport.py b/tests/core/test_transport.py index d8ed47e..2b4a01a 100644 --- a/tests/core/test_transport.py +++ b/tests/core/test_transport.py @@ -3,6 +3,7 @@ import random as random_module from collections.abc import Iterator from datetime import UTC, datetime, timedelta +from email.utils import format_datetime import httpx import pytest @@ -25,6 +26,7 @@ UpstreamApiError, ValidationError, ) +from avito.core.rate_limit import RateLimiter from avito.core.retries import RetryPolicy from avito.core.types import ApiTimeouts from avito.testing import FakeTransport @@ -158,6 +160,53 @@ def test_retry_policy_uses_full_jitter_with_cap() -> None: assert first_delay != second_delay +def test_rate_limiter_waits_before_bucket_overflow() -> None: + now = {"value": 0.0} + sleeps: list[float] = [] + + def sleep(delay: float) -> None: + sleeps.append(delay) + now["value"] += delay + + limiter = RateLimiter( + RetryPolicy( + rate_limit_enabled=True, + rate_limit_requests_per_second=2.0, + rate_limit_burst=1, + ), + clock=lambda: now["value"], + sleep=sleep, + ) + + assert limiter.acquire() == 0.0 + assert limiter.acquire() == pytest.approx(0.5) + assert sleeps == [pytest.approx(0.5)] + + +def test_rate_limiter_uses_remaining_header_as_short_cooldown() -> None: + now = {"value": 0.0} + sleeps: list[float] = [] + + def sleep(delay: float) -> None: + sleeps.append(delay) + now["value"] += delay + + limiter = RateLimiter( + RetryPolicy( + rate_limit_enabled=True, + rate_limit_requests_per_second=4.0, + rate_limit_burst=10, + ), + clock=lambda: now["value"], + sleep=sleep, + ) + + limiter.observe_response(headers={"X-RateLimit-Remaining": "0"}) + + assert limiter.acquire() == pytest.approx(0.25) + assert sleeps == [pytest.approx(0.25)] + + def test_transport_does_not_retry_non_idempotent_request_without_explicit_permission() -> None: calls = {"count": 0} @@ -322,6 +371,30 @@ def test_transport_preserves_retry_after_header_value() -> None: assert error.value.retry_after == 0.01 +def test_transport_parses_retry_after_http_date() -> None: + retry_at = datetime.now(UTC) + timedelta(seconds=10) + transport = Transport( + make_settings(retry_policy=RetryPolicy(max_attempts=1)), + client=httpx.Client( + transport=httpx.MockTransport( + lambda request: httpx.Response( + 429, + json={"message": "Слишком много запросов."}, + headers={"Retry-After": format_datetime(retry_at, usegmt=True)}, + ) + ), + base_url="https://api.avito.ru", + ), + sleep=lambda _: None, + ) + + with pytest.raises(RateLimitError) as error: + transport.request_json("GET", "/limited", context=RequestContext("limited")) + + assert error.value.retry_after is not None + assert 0 < error.value.retry_after <= 10 + + def test_transport_uses_half_second_retry_after_default_without_header() -> None: transport = Transport( make_settings(retry_policy=RetryPolicy(max_attempts=1)), @@ -343,6 +416,35 @@ def test_transport_uses_half_second_retry_after_default_without_header() -> None assert error.value.retry_after == 0.5 +def test_transport_retries_rate_limit_without_retry_after_using_backoff() -> None: + responses = iter( + ( + httpx.Response(429, json={"message": "Слишком много запросов."}), + httpx.Response(200, json={"ok": True}), + ) + ) + sleeps: list[float] = [] + transport = Transport( + make_settings( + retry_policy=RetryPolicy( + max_attempts=2, + backoff_factor=1.0, + random_source=random_module.Random(2), + ) + ), + client=httpx.Client( + transport=httpx.MockTransport(lambda request: next(responses)), + base_url="https://api.avito.ru", + ), + sleep=sleeps.append, + ) + + payload = transport.request_json("GET", "/limited", context=RequestContext("limited")) + + assert payload == {"ok": True} + assert sleeps == [pytest.approx(random_module.Random(2).random())] + + def test_transport_raises_mapping_error_for_invalid_json() -> None: transport = Transport( make_settings(), diff --git a/tests/domains/ads/test_ads.py b/tests/domains/ads/test_ads.py index ab83f6d..89a4e0f 100644 --- a/tests/domains/ads/test_ads.py +++ b/tests/domains/ads/test_ads.py @@ -19,24 +19,24 @@ def handler(request: httpx.Request) -> httpx.Response: assert request.url.path == "/core/v1/items" assert request.url.params["user_id"] == "7" assert request.url.params["status"] == "active" - assert request.url.params["limit"] == "2" + assert request.url.params["per_page"] == "2" - offset = request.url.params["offset"] - seen_offsets.append(offset) + page = request.url.params["page"] + seen_offsets.append(page) page_items = { - "0": [{"id": 101, "title": "Смартфон"}, {"id": 102, "title": "Ноутбук"}], + "1": [{"id": 101, "title": "Смартфон"}, {"id": 102, "title": "Ноутбук"}], "2": [{"id": 103, "title": "Планшет"}, {"id": 104, "title": "Наушники"}], - "4": [{"id": 105, "title": "Камера"}], + "3": [{"id": 105, "title": "Камера"}], } - return httpx.Response(200, json={"items": page_items[offset], "total": 5}) + return httpx.Response(200, json={"items": page_items[page], "total": 5}) ad = Ad(make_transport(httpx.MockTransport(handler)), user_id=7) items = ad.list(status="active", page_size=2) - assert seen_offsets == ["0"] + assert seen_offsets == ["1"] assert items[3].item_id == 104 - assert seen_offsets == ["0", "2"] + assert seen_offsets == ["1", "2"] assert [item.title for item in items.materialize()] == [ "Смартфон", "Ноутбук", @@ -44,7 +44,7 @@ def handler(request: httpx.Request) -> httpx.Response: "Наушники", "Камера", ] - assert seen_offsets == ["0", "2", "4"] + assert seen_offsets == ["1", "2", "3"] def test_ads_list_limit_is_total_cap_not_page_size() -> None: @@ -53,14 +53,14 @@ def test_ads_list_limit_is_total_cap_not_page_size() -> None: def handler(request: httpx.Request) -> httpx.Response: assert request.url.path == "/core/v1/items" - seen_limits.append(request.url.params["limit"]) - offset = request.url.params["offset"] - seen_offsets.append(offset) + seen_limits.append(request.url.params["per_page"]) + page = request.url.params["page"] + seen_offsets.append(page) page_items = { - "0": [{"id": 101}, {"id": 102}], + "1": [{"id": 101}, {"id": 102}], "2": [{"id": 103}], } - return httpx.Response(200, json={"items": page_items[offset], "total": 5}) + return httpx.Response(200, json={"items": page_items[page], "total": 5}) ad = Ad(make_transport(httpx.MockTransport(handler)), user_id=7) @@ -68,7 +68,25 @@ def handler(request: httpx.Request) -> httpx.Response: assert [item.item_id for item in items.materialize()] == [101, 102, 103] assert seen_limits == ["2", "1"] - assert seen_offsets == ["0", "2"] + assert seen_offsets == ["1", "2"] + + +def test_ads_list_does_not_treat_limit_as_total_when_api_omits_total() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/core/v1/items" + assert request.url.params["per_page"] == "50" + assert request.url.params["page"] == "1" + return httpx.Response(200, json={"items": [{"id": item_id} for item_id in range(101, 126)]}) + + ad = Ad(make_transport(httpx.MockTransport(handler)), user_id=7) + + items = ad.list(limit=50) + + assert len(items) == 25 + assert items.loaded_count == 25 + assert items.known_total is None + assert items.source_total is None + assert [item.item_id for item in items.materialize()] == list(range(101, 126)) def test_ads_domain_covers_item_stats_spendings_and_promotion() -> None: diff --git a/tests/domains/promotion/test_promotion.py b/tests/domains/promotion/test_promotion.py index 9fa5dee..75346a7 100644 --- a/tests/domains/promotion/test_promotion.py +++ b/tests/domains/promotion/test_promotion.py @@ -17,7 +17,11 @@ TargetActionPricing, TrxPromotion, ) -from avito.promotion.enums import PromotionStatus, TargetActionBudgetType, TargetActionSelectedType +from avito.promotion.enums import ( + PromotionOrderServiceStatus, + TargetActionBudgetType, + TargetActionSelectedType, +) from avito.promotion.models import ( BbipItem, ) @@ -277,8 +281,8 @@ def handler(request: httpx.Request) -> httpx.Response: first_bids = pricing.get_bids() second_bids = pricing.get_bids() - assert first_service.status is PromotionStatus.UNKNOWN - assert second_service.status is PromotionStatus.UNKNOWN + assert first_service.status is PromotionOrderServiceStatus.UNKNOWN + assert second_service.status is PromotionOrderServiceStatus.UNKNOWN assert first_bids.selected_type is TargetActionSelectedType.UNKNOWN assert second_bids.selected_type is TargetActionSelectedType.UNKNOWN assert first_bids.auto is not None @@ -289,7 +293,7 @@ def handler(request: httpx.Request) -> httpx.Response: status_records = [ record for record in caplog.records - if getattr(record, "enum", None) == "promotion.status" + if getattr(record, "enum", None) == "promotion.order_service_status" and getattr(record, "value", None) == "mystery-promotion-status" ] selected_type_records = [ diff --git a/todo.md b/todo.md index a8064b0..cef6d52 100644 --- a/todo.md +++ b/todo.md @@ -1,580 +1,435 @@ -# Документация avito-py на MkDocs Material +# Swagger Binding Decorator -## Context +## Цель -SDK `avito-py` покрывает 204 операции Avito API через 11 публичных доменных пакетов (`accounts`, `ads`, `autoteka`, `cpa`, `jobs`, `messenger`, `orders`, `promotion`, `ratings`, `realty`, `tariffs`) и 58 фабричных методов на `AvitoClient`. Число публичных доменов **не хардкодится** — вычисляется как уникальные значения колонки `пакет_sdk` в `docs/avito/inventory.md`, исключая `auth`, `core` и `testing`. +В SDK должен быть единый машинно-проверяемый способ связать публичный SDK-метод с операцией из Swagger-спецификации. -Сейчас у пользователя есть `README.md` с quickstart и доменными how-to snippet'ами (как требует STYLEGUIDE § Documentation Structure), русские docstring'и в публичном API и `CHANGELOG.md` в корне. Docstring'и не считать готовыми к строгому reference-гейту: перед включением `pydocstyle`/`interrogate` нужен отдельный проход по публичным контрактам, потому что часть docstring'ов сейчас короткая и не покрывает Returns/Raises/идемпотентность по STYLEGUIDE. **Каркас сайта уже создан** (PR 1 в основном реализован) — см. раздел «Текущее состояние». +Swagger-файлы в `docs/avito/api/*.json` являются единственным источником истины по API-контракту: -STYLEGUIDE § Documentation Structure делает обязательными все четыре режима Diátaxis (tutorials / how-to / reference / explanations); usability_scorecard § 15 выделяет на документацию 7% итогового Score и фиксирует шесть подкритериев с измеримыми процедурами. +- HTTP method; +- path; +- path/query/header parameters; +- request body; +- content-type; +- response statuses; +- response schemas; +- error schemas; +- deprecated state. -Цель — **стабилизировать существующий каркас** и достроить сайт, **не подменяя README** (доменные how-to snippets в README остаются — это нормативное требование STYLEGUIDE.md:678), а дополняя его режимами, которые в README невозможно компактно уместить: полный reference, длинные how-to с диаграммами, explanations и deploy-версионирование. +Декоратор и class-level metadata не должны дублировать API-контракт. Они описывают только: -**Измеримые цели**: +```text +какой SDK method соответствует какой Swagger operation +как contract-test runner должен вызвать этот SDK method +``` + +## Сценарий Выполнения + +Перед внедрением binding-ов нужно обновить локальные Swagger/OpenAPI спецификации из публичного каталога Авито: + +```bash +poetry run python scripts/download_avito_api_specs.py +``` + +Сценарий работ: + +1. Запустить `poetry run python scripts/download_avito_api_specs.py`, чтобы `docs/avito/api/*.json` отражали актуальный источник API-контрактов. +2. Проверить diff Swagger-файлов и зафиксировать, если изменилось число операций, `operation_id`, `deprecated`, paths, параметры или схемы. +3. Реализовать `avito/core/swagger.py` с `SwaggerOperationBinding` и `@swagger_operation(...)`. +4. Расставить class-level metadata и decorators на публичных domain methods без дублирования Swagger-контракта. +5. Реализовать `scripts/lint_swagger_bindings.py` и `make swagger-lint`. +6. Добавить `make swagger-lint` в общий quality gate. +7. Добавить unit-тесты декоратора, линтера и contract tests через `SwaggerFakeTransport`. +8. Завершить проверкой: + +```bash +make check +make swagger-lint +``` + +## 1. Публичный API Декоратора + +Модуль: + +```text +avito/core/swagger.py +``` + +Основной декоратор: + +```python +@swagger_operation( + method: str, + path: str, + *, + spec: str | None = None, + operation_id: str | None = None, + factory: str | None = None, + factory_args: Mapping[str, str] | None = None, + method_args: Mapping[str, str] | None = None, + deprecated: bool = False, + legacy: bool = False, +) +``` + +Пример: + +```python +class Chat: + __swagger_domain__ = "messenger" + __swagger_spec__ = "Мессенджер.json" + __sdk_factory__ = "chat" + + @swagger_operation( + "GET", + "/messenger/v1/accounts/{user_id}/chats/{chat_id}", + factory_args={ + "user_id": "path.user_id", + "chat_id": "path.chat_id", + }, + ) + def get(self) -> ChatInfo: + ... +``` + +## 2. Class-Level Metadata + +Публичные domain objects и section clients могут объявлять служебные поля: + +```python +__swagger_domain__: str +__swagger_spec__: str +__sdk_factory__: str +__sdk_factory_args__: Mapping[str, str] +``` + +Назначение: + +```text +__swagger_domain__ + Логический домен SDK: ads, messenger, orders, promotion, accounts и т.д. + Используется для группировки contract tests и отчетов линтера. + +__swagger_spec__ + Имя Swagger-файла из docs/avito/api/. + Используется как default spec для всех decorated методов класса. + +__sdk_factory__ + Имя factory method на AvitoClient. + Например: "chat" означает client.chat(...). + +__sdk_factory_args__ + Default mapping аргументов factory. + Используется, если method-level factory_args не указан. +``` + +Приоритет значений: -- новичок (P1) доходит от `pip install` до `get_self()` за ≤15 минут (scorecard 1.6); -- опытный разработчик (P2) находит нужный метод без чтения исходников (scorecard 2.*); -- сопровождающий (P3) видит совместимость и deprecation без заглядывания в git-лог (scorecard 18.*); -- `debug_info()` документирован в reference (`client.md`) и покрыт supporting-gate `7.3_debug_info_safe_by_default` в `docs-quality-report.json`. -- `reference/exceptions.md` документирует публичные атрибуты ошибок (`operation`, `status`, `request_id`, `attempt`, `method`, `endpoint`), и `check_public_docstrings.py` проверяет, что методы, поднимающие исключения, описывают доступные поля. -- `reference/enums.md` генерируется автоматически как индекс всех публичных `Enum` из `avito..__all__`; `check_reference_public_surface.py` проверяет полноту. -- `explanations/security-and-redaction.md` фиксирует security-модель SDK: редакция секретов в логах/исключениях/сериализации и публичные гарантии. -- scorecard §15.1–15.6 закрыт отдельно; scorecard §12 (async/sync) явно помечен как disabled, вес перераспределён в `disabled_criteria` поле `docs-quality-report.json`; все `supporting_gates.*` имеют `grade ∈ {0, 0.25, 0.5, 0.75, 1.0}` и non-null `evidence` — `null` считается провалом гейта. -- P2 может написать consumer-side test поверх SDK через документированный `avito.testing` без приватных полей и без реального HTTP (scorecard 16.1–16.2); -- Diátaxis-матрица 4×N заполнена; каждый публичный домен имеет минимум одну how-to на сайте (в дополнение к snippet'у в README); явный маппинг «домен → файл» зафиксирован в `docs-quality-report.json`. -- `docs-quality-report` покрывает не только scorecard §15, но и supporting-gates для §16.1–16.4 и §18.1–18.5, потому что production-ready docs здесь завязана на public testing contract и deprecation/CHANGELOG contract, а не только на markdown-страницы. +```text +1. Значения из @swagger_operation(...) +2. Значения из class-level metadata +3. Auto-resolve через Swagger registry, если это безопасно и однозначно +``` + +## 3. Binding Model -Язык — только русский. Визуализация — сбалансированная (Mermaid, admonitions, tabbed code, без кастомной темы). Версионирование — через `mike`. +Декоратор должен записывать metadata в атрибут функции: + +```python +func.__swagger_binding__ +``` -## Текущее состояние (уже реализовано) +Тип: + +```python +@dataclass(frozen=True, slots=True) +class SwaggerOperationBinding: + method: str + path: str + spec: str | None + operation_id: str | None + factory: str | None + factory_args: Mapping[str, str] + method_args: Mapping[str, str] + deprecated: bool + legacy: bool +``` + +Требования: + +- `method` нормализуется в uppercase. +- `path` хранится в Swagger-формате: `/path/{param}`. +- `factory_args` и `method_args` внутри модели должны быть immutable mapping. +- Декоратор не должен менять поведение метода. +- Декоратор не должен выполнять загрузку Swagger-файлов на import time. + +## 4. Запрещенные Поля + +В декораторе запрещены любые поля, дублирующие Swagger-контракт: + +```python +response_model=... +request_model=... +request_schema=... +response_schema=... +success_statuses=... +error_statuses=... +content_type=... +required_fields=... +query_params=... +path_params=... +``` -PR 1 в основном сделан: +Причина: это создает второй источник истины и допускает расхождение со Swagger. -- `mkdocs.yml` существует, настроен на `docs_dir: docs/site`. -- Группа `docs` в `pyproject.toml` содержит `mkdocs-material`, `mkdocs-awesome-pages-plugin`, `mkdocs-include-markdown-plugin`, `mike`. -- `.github/workflows/docs.yml` собирает сайт и деплоит через `mike`. -- Цели `docs-serve` / `docs-build` в `Makefile`. -- `[tool.poetry.urls].Documentation` указан в метаданных Poetry. -- Структура `docs/site/` с плейсхолдерами четырёх Diátaxis-разделов. -- `docs/site/index.md` — hero + три карточки + Diátaxis-карта. -- `docs/site/tutorials/getting-started.md` — первый рабочий tutorial. -- `docs/site/tutorials/first-promotion.md` — плейсхолдер. -- `docs/site/changelog.md` — include корневого `CHANGELOG.md`. +## 5. Path Expressions -**Что сломано в текущем состоянии** (надо починить в PR 1): +`factory_args` и `method_args` описывают, как contract-test runner строит SDK-вызов из Swagger-generated request data. -1. `mkdocs build --strict` падает с 8 предупреждениями (strict-mode превращает warning → error): - - Nav-ссылки вида `tutorials/` в `mkdocs.yml` не разрешаются до того, как плагин awesome-pages обработает nav; решение — удалить `nav` из `mkdocs.yml` и завести `docs/site/.pages` с порядком разделов и русскими именами вкладок. - - `docs/site/index.md` содержит ссылку `../avito/inventory.md` — файл вне `docs_dir`; решить добавлением `docs/site/reference/coverage.md` и ссылкой на неё. - - Несколько ссылок из `tutorials/getting-started.md` ведут на страницы, которые ещё не созданы (`how-to/auth-and-config.md`, `reference/client.md`); добавить плейсхолдеры. -2. `avito.testing.__init__` экспортирует только `FakeTransport` и `FakeResponse`, но `fake_transport.py` объявляет в `__all__` также `JsonValue`, `RecordedRequest`, `json_response`, `route_sequence` — нужные в how-to/reference примерах; синхронизировать публичный export. -3. `mkdocs.yml` не верифицирован на поддержку mermaid через `pymdownx.superfences.custom_fences`; если конфигурации нет, добавить в PR 1. +Разрешенные выражения: -## Зафиксированные решения +```text +path. path parameter +query. query parameter +header. header parameter +body весь request body +body. поле request body +constant. тестовая константа из controlled test registry +``` -### Навигация +Примеры: -- Material-фичи: `navigation.tabs`, `navigation.sections`, `navigation.indexes`, `navigation.top`, `toc.follow`. -- `nav` удаляется из `mkdocs.yml`; nav управляется файлами `.pages` (awesome-pages plugin). -- Корневой `docs/site/.pages`: - ```yaml - nav: - - Главная: index.md - - Tutorials: tutorials - - How-to: how-to - - Reference: reference - - Explanations: explanations - - Changelog: changelog.md - ``` +```python +factory_args={ + "user_id": "path.user_id", + "item_id": "path.item_id", +} +``` -### Код-блоки и аннотации +```python +method_args={ + "request": "body", +} +``` -`content.code.annotate` **остаётся включённым глобально** — это фича рендеринга, не синтаксис Python. Проблема не в ней, а в аннотационном синтаксисе `# (N)!` внутри Python-блоков: mktestdocs передаёт блок в Python как есть, и `# (1)!` — невалидный Python-комментарий в Material-смысле (хотя технически парсится, маркер-символ может путать инструменты). Поэтому: +```python +method_args={ + "limit": "query.limit", + "offset": "query.offset", +} +``` -- **Правило**: в `tutorials/*.md` и `how-to/*.md` Python-блоки никогда не содержат аннотационных маркеров `# (N)!`. Это plain fenced code без Material-специфичного синтаксиса. -- `pymdownx.tabbed` тоже не используется в tutorials/how-to. -- В `explanations/` и `reference/` аннотации и вкладки разрешены — mktestdocs там не применяется. +Ограничения: -### Исполняемость примеров (mktestdocs harness) +- `factory_args` и `method_args` не должны содержать Python expressions. +- Запрещены произвольные callables. +- Запрещены dotted paths, которые не относятся к Swagger request. +- `constant.*` разрешается только для заранее зарегистрированных тестовых констант. -`mktestdocs` через `pytest tests/docs/`. Финальная политика: **все fenced code-блоки с меткой `python` или `pycon` в `README.md`, `tutorials/*.md` и `how-to/*.md` исполняются**. Bash, env, mermaid — не исполняются (нет метки `python`). +## 6. Swagger Operation Identity -Правила классификации примеров: +Операция определяется ключом: -- если блок показывает SDK-вызов и помечен как `python`/`pycon`, он обязан выполняться через docs-harness без сети; -- если блок иллюстративный и не должен исполняться, он не имеет метки `python`/`pycon` (`text`, `console`, `bash` и т.п.) и не считается copy-paste примером; -- в `reference/` и `explanations/` Python-блоки либо подключаются к тому же collector'у, либо заменяются на non-executable fence; скрытых непроверяемых SDK-примеров быть не должно; -- реальный HTTP допускается только в ручной TTFC-процедуре с настоящими ключами, а не в CI. +```text +spec + method + normalized_path +``` -**Проблема изоляции**: `tutorials/getting-started.md:47` вызывает `AvitoClient.from_env().account().get_self()` — это реальный HTTP-запрос. В CI без API-секретов он упадёт. Базовое решение для PR 3 — `tests/docs/conftest.py` с pytest-фикстурой, которая: -1. Monkeypatches `AvitoClient.from_env()` → возвращает lightweight docs-test facade, повторяющий только публичные методы, используемые в README/tutorials/how-to (`account()`, `ad()`, и т.д.). -2. Facade внутри использует настоящие доменные объекты SDK, созданные поверх `FakeTransport.build()`, чтобы проверялись реальные публичные вызовы доменов без сетевого доступа. -3. FakeTransport скриптован `route_sequence` на типичные ответы (get_self, get_items, и т.д.), покрывающие все README/tutorials/how-to. -4. Env-переменные `AVITO_CLIENT_ID`/`AVITO_CLIENT_SECRET` устанавливаются в фикстуре как заглушки. +Если `spec` не указан, operation может быть найдена по: -Ограничение harness: monkeypatch только `AvitoClient.from_env()` покрывает tutorial-путь, но не покрывает Python-блоки, где конструируется `AvitoClient(client_id=..., client_secret=...)` или `AvitoClient(AvitoSettings(...))` и затем выполняется SDK-вызов. Зафиксированный контракт: +```text +method + normalized_path +``` -- в executable examples сетевые SDK-вызовы идут через `AvitoClient.from_env()`; -- остальные способы инициализации (`AvitoClient(client_id=...)`, `AvitoClient(AvitoSettings(...))`) можно показывать, но без вызова методов, которые идут в transport; -- consumer-testing примеры используют `FakeTransport.as_client()` после добавления публичного testing API в PR 3; -- если документации нужен executable пример с прямым `AvitoClient(...)` и последующим SDK-вызовом, сначала расширяется docs-harness публичным тестовым API; monkeypatch приватных полей запрещён. +только если совпадение среди всех Swagger-файлов ровно одно. -Не использовать хрупкий вариант «создать настоящий `AvitoClient`, потом заменить internals»: у `AvitoClient` нет публичного параметра `transport`, а STYLEGUIDE требует иммутабельности клиента после создания. Если в будущем понадобится полноценный `AvitoClient` с fake transport для docs-тестов, это отдельное публичное/тестовое API-решение, а не monkeypatch приватных полей. +`operation_id`, если указан, является дополнительной проверкой, а не основным источником истины. -Это позволяет README/tutorials/how-to показывать реальный API (`from_env()`) для P1-аудитории, и при этом тестировать код без сетевого доступа. Скрипты, которые явно импортируют `FakeTransport` (how-to `testing-with-fake-transport.md`), работают напрямую без monkeypatch. +## 7. Линтер -**Дизайнерское правило**: каждый новый Python-блок в README/tutorials/how-to обязан работать с harness conftest без сетевых запросов. Если блок требует API-секрет или настоящий transport, это дефект документации, не test-skip. +Нужен отдельный CLI-линтер: -`scripts/check_docs_examples.py` публикует `reference-explanation-examples-report.json`; в PR 3 gate включается strict. Поле `reference_explanation_examples_gaps` добавляется в `docs-quality-report.json`. +```bash +poetry run python scripts/lint_swagger_bindings.py +``` -### Страница «Покрытие API» (coverage.md) +И make target: -`docs/site/reference/coverage.md` — статическая страница внутри `docs_dir`. Она **не ссылается относительными ссылками на `docs/avito/`** (они вне docs_dir и сломают strict-mode). Вместо этого ссылки на Swagger-схемы идут через GitHub blob URL вида `https://github.com///blob/main/docs/avito/api/.json`. Все файлы в `docs/avito/api/` имеют расширение **`.json`**, не `.yaml`. +```bash +make swagger-lint +``` -**Важно**: `mkdocs.yml:4` сейчас содержит `repo_url: https://github.com/p141592/avito`, при этом `site_url`, badge coverage и локальный каталог проекта указывают на `avito_python_api`. До создания `coverage.md` нужно выбрать один canonical repo URL и синхронно обновить `mkdocs.yml`, Poetry metadata и badges. Если URL окажется неверным, blob-ссылки из coverage.md будут 404. Правило: blob-ссылки в coverage.md хардкодятся с верифицированным URL репозитория и обновляются при смене repo_url в mkdocs.yml; генерировать их динамически из конфига mkdocs не нужно (coverage.md меняется редко). +Линтер должен запускаться вместе с общей проверкой качества проекта. -### Синхронизация specs ↔ inventory +## 8. Что Проверяет Линтер -`docs/avito/api/*.json` остаётся **единственным authoritative source of truth** по API-контракту; `docs/avito/inventory.md` — это производный индекс для SDK/discovery/doc-generation, а не замена Swagger/OpenAPI-спекам. Поэтому финальный DoD не может опираться только на `inventory-coverage-report.json`. +### 8.1 Swagger Files -Зафиксированный контракт: +Линтер загружает все файлы: -- `scripts/check_spec_inventory_sync.py --output spec-inventory-report.json` сверяет все операции из Swagger/OpenAPI-документов с таблицей `inventory.md`; -- отчёт проверяет как минимум `method + path + документ + раздел`, а не только общее количество строк; -- наличие операции в spec и отсутствие в inventory — дефект inventory; -- наличие операции в inventory без соответствующей spec-записи — дефект inventory или устаревшая запись; -- `coverage.md` может ссылаться на inventory как на удобный индекс, но CI-гейт по полноте строится отдельно через `check_spec_inventory_sync.py`. +```text +docs/avito/api/*.json +``` -### Deprecated-политика (docs и runtime — разные работы) +Проверяет: -- *Сайт*: `_gen_reference.py` рендерит `!!! warning "Устаревшая операция"` из inventory. Если inventory содержит явное поле `replacement` (см. «Расширение inventory» ниже), генератор добавляет ссылку. Если нет — warning рендерится без replacement; эвристического вывода нет. -- *Runtime*: каждый публичный SDK-символ с `deprecated: да` **обязан** эмитировать `DeprecationWarning` при первом вызове с указанием replacement и целевой версии удаления (STYLEGUIDE § Deprecation Policy). Отсутствие `replacement` в inventory — недостаток inventory, а не повод обходить runtime-требование. -- *Gap-отчёт*: `scripts/check_inventory_coverage.py` (отдельный скрипт — не `_gen_reference.py`) пишет `inventory-coverage-report.json`. Включает `deprecated_without_replacement` для операций без заполненного поля `replacement`. +- JSON валиден; +- Swagger/OpenAPI структура поддерживается; +- все paths и operations извлекаются; +- каждая operation имеет стабильный ключ; +- нет дублей `spec + method + path`; +- path parameters в path совпадают с parameters/request definition. -Runtime deprecation warnings — это изменение поведения публичного SDK, а не документационная задача. Его нельзя считать частью автогенерации reference. Реализация runtime warnings, тест `tests/contracts/test_deprecation_warnings.py` и запись в `CHANGELOG.md` идут отдельным SDK-contract блоком в PR 2.5/PR 3 до финального DoD. +### 8.2 SDK Binding Discovery -PR 2.5 мержится до PR 2b. Причина: deprecated-admonition в reference не должен появляться раньше, чем runtime `DeprecationWarning` эмитируется по вызову символа. +Линтер импортирует пакет `avito` и находит все функции/методы с: -### Per-operation overrides — канонический набор +```python +__swagger_binding__ +``` -Канонический набор overrides фиксируется в `reference/config.md` как таблица «тип операции → разрешённые overrides»: +Для каждого binding определяет: + +- module; +- class name; +- method name; +- effective `spec`; +- effective `factory`; +- effective `factory_args`; +- effective `method_args`; +- class-level metadata. + +### 8.3 Completeness + +Обязательные проверки: + +```text +1. Каждая Swagger operation имеет ровно один SDK binding. +2. Каждый SDK binding указывает на существующую Swagger operation. +3. Две SDK methods не могут ссылаться на одну Swagger operation. +4. Один SDK method не может иметь несколько bindings, кроме явно разрешенной политики. +5. spec из binding/class metadata должен существовать в docs/avito/api/. +6. method/path должны совпадать с operation из Swagger. +7. operation_id, если указан, должен совпадать со Swagger. +``` -- read / list / probe: `timeout`, `retries`; -- write с `dry_run=False`: `timeout`, `retries`, `idempotency_key`; -- write с `dry_run=True`: `timeout`; -- pagination-чтение: `timeout`, `retries`, `page_size`. +### 8.4 Deprecated / Legacy -`check_public_docstrings.py` сверяет реальные override-параметры с этим списком; расхождение — docstring gap. +Проверки: -### Exception metadata contract +```text +1. Если Swagger operation deprecated=true, binding должен иметь deprecated=True. +2. Если binding deprecated=True, Swagger operation тоже должна быть deprecated. +3. Если политика SDK требует legacy domain для deprecated operations, binding должен иметь legacy=True. +4. Non-deprecated operation не может иметь legacy=True без явного исключения. +``` -Каждый подкласс `AvitoError` обязан в `reference/exceptions.md` документировать поля: `operation`, `status`, `request_id`, `attempt`, `method`, `endpoint`. `check_public_docstrings.py` добавляет шестой обязательный аспект — «documented raised exceptions include metadata field list». +Исключения, если они понадобятся, должны быть описаны в отдельном allowlist-файле с причиной и датой удаления. По умолчанию allowlist запрещен. -### Security surface docs +### 8.5 Factory Validation -`explanations/security-and-redaction.md` покрывает: (1) что SDK гарантирует в логах и ошибках по STYLEGUIDE §Logging/§Errors, (2) контракт `debug_info()`, (3) как consumer-код не должен логировать результаты `to_dict()` без фильтрации. +Для каждого binding: -### Scorecard §12 disabled +```text +1. factory должен существовать на AvitoClient. +2. Если factory не указан в decorator, должен быть __sdk_factory__ на классе. +3. factory_args должны соответствовать сигнатуре factory. +4. method_args должны соответствовать сигнатуре decorated SDK method. +5. Required параметры factory/method должны быть покрыты mapping-ом. +6. В mapping не должно быть лишних аргументов. +``` -SDK синхронный (STYLEGUIDE § HTTP and Transport Layer). Scorecard §12 (async/sync parity) отключён; его 3% веса перераспределяются пропорционально между §2 (+0.24%), §4 (+0.30%), §5 (+0.18%), §6 (+0.24%), §8 (+0.21%), §13 (+0.12%), §15 (+0.21%), §16 (+0.15%), §18 (+0.15%), остальным — согласно формуле Σ weight = 100%. Зафиксировано в `disabled_criteria` поле `docs-quality-report.json`. +### 8.6 Path Expression Validation -### TTFC runbook +Для каждого выражения в `factory_args` и `method_args`: -TTFC-замер выполняется вручную ответственным мейнтейнером перед каждым релизом (и перед финальным DoD). Процедура фиксируется в `CONTRIBUTING.md` раздел «TTFC measurement». Результат пишется в поле `ttfc_minutes` текущего релизного `docs-quality-report.json`. +```text +path. + должен существовать среди path parameters Swagger operation. -### Расширение inventory (prerequisite финального DoD) +query. + должен существовать среди query parameters Swagger operation. -`docs/avito/inventory.md` сейчас не содержит колонок `deprecated_since`, `replacement` и `removal_version`. Без них финальный DoD (`deprecated_without_replacement` пуст и deprecation-период ≥2 minor) **недостижим** — это не дефект плана сайта, это gap в source of truth. В scope PR 2 входит: +header. + должен существовать среди header parameters Swagger operation. -1. Добавить колонки `deprecated_since`, `replacement` и `removal_version` в таблицу операций `docs/avito/inventory.md`. -2. Заполнить значения для всех записей с `deprecated: да`. -3. Обновить `scripts/parse_inventory.py` для разбора новых колонок (`InventoryRow.deprecated_since: str | None`, `InventoryRow.replacement: str | None`, `InventoryRow.removal_version: str | None`). -4. Добавить sanity-check inventory: описание с `(deprecated)` не может иметь `deprecated: нет`; `deprecated: да` не может быть без `deprecated_since`, `replacement`, `removal_version`; `removal_version` должен быть не раньше чем через два minor-релиза после `deprecated_since`. +body + Swagger operation должна иметь request body. -До заполнения этих полей финальный DoD не применяется; промежуточные PR мержатся с непустым отчётом. +body. + Swagger operation должна иметь request body schema, где поле существует. -### Инструмент проверки ссылок (lychee) +constant. + должен существовать в test constants registry. +``` -`lychee` — не Python-зависимость. Для `make docs-check` требует отдельной установки: +### 8.7 Запрет Дублирования Контракта -- macOS: `brew install lychee` -- Linux/CI: `cargo binstall lychee` или GitHub Action [`lycheeverse/lychee-action`](https://github.com/lycheeverse/lychee-action) +Линтер должен падать, если в `SwaggerOperationBinding` или decorator call появляются запрещенные поля: -Установка документируется в `CONTRIBUTING.md`. Если lychee не найден — `make docs-check` падает с понятным сообщением (не silent skip). В CI lychee запускается через GitHub Action, не через Makefile. +- statuses; +- schemas; +- content types; +- response models; +- request models; +- error models. -Конфигурация: `--exclude "avito\.ru"`, `--retry-wait-time 5`, `--max-retries 3`, `--timeout 30`. +Это можно проверять через сигнатуру декоратора и unit-тесты самого декоратора. -Для локальной работы без lychee доступна цель `make docs-strict` (только `mkdocs build --strict` + Python-gates). +## 9. Формат Ошибок Линтера -### Прочие решения +Ошибка должна быть точной и actionable. -- **Автогенерация reference**: `mkdocstrings[python]` + `mkdocs-gen-files` + `mkdocs-literate-nav`. Генерируемые файлы (`reference/domains/*.md`, `reference/operations.md`, `reference/SUMMARY.md`) **не коммитятся** — создаются через `mkdocs_gen_files.open()` как виртуальные. -- **Mermaid**: `mkdocs.yml` включает `pymdownx.superfences` с `custom_fences` для `name: mermaid` и `class: mermaid`. В PR 1 каркас верифицируется рендером одной mermaid-диаграммы в `explanations/architecture.md` (минимальный placeholder до PR 3). -- **Версионирование**: фиксируем конкретную схему `mike`. На `push` в `main` деплоится docs-version `main` с alias `latest` через `mike deploy --push --update-aliases main latest`, затем `mike set-default --push latest`. На `push` тега `vX.Y.Z` деплоится docs-version `X.Y.Z` с alias `stable` через `mike deploy --push --update-aliases X.Y.Z stable`. Root redirect всегда ведёт на alias `latest`; `stable` — это последний релизный docs-snapshot, а не default alias. -- **mkdocstrings-зависимость**: `mkdocstrings = { version = ">=0.27", extras = ["python"] }`. -- **Inventory parser**: `scripts/parse_inventory.py` — reusable, возвращает `list[InventoryRow]` (frozen dataclass). Используется в `_gen_reference.py`, `check_readme_domain_coverage.py`, `check_inventory_coverage.py`, `check_docs_examples.py`, `check_spec_inventory_sync.py`. -- **Разделение ответственности**: `_gen_reference.py` только читает inventory и рендерит страницы. `scripts/check_inventory_coverage.py --output inventory-coverage-report.json` — владелец contract-отчёта. -- **Reference public surface**: generated reference ориентируется на фактическую публичную поверхность: `avito.__all__`, `avito..__all__`, `avito.testing.__all__`, все публичные `Enum` из доменных `__all__` и явные страницы для top-level contract (`AvitoClient`, `AvitoClient.debug_info`, `AvitoSettings`, `AuthSettings`, `PaginatedList`, exceptions). Отдельный скрипт `scripts/check_reference_public_surface.py --output reference-public-report.json` проверяет две вещи: все публичные экспорты попали в reference ровно один раз; internal/private символы вне экспортируемой поверхности не попали в `SUMMARY.md` и discovery-индекс. -- **CI fetch-depth**: `fetch-depth: 0` добавляется в `ci.yml` в PR 3 (нужен для `interrogate` diff-gate против `origin/main`). -- **poetry.lock**: каждый PR, добавляющий зависимости в `pyproject.toml`, коммитит обновлённый `poetry.lock`. Для Poetry 2.x используется `poetry lock` — опции `--no-update` больше нет, а сохранение уже зафиксированных версий является поведением по умолчанию. -- **Контракт README**: `scripts/check_readme_domain_coverage.py` читает домены из inventory через `parse_inventory.py` (не хардкоженный список), выходит с ненулевым кодом при пропущенных; включён в `make docs-strict` и `make docs-check`. -- **pydocstyle**: отдельная цель `make qa-docs`, не в `make lint`. -- **interrogate**: PR 2b — report-only; PR 3 — gate только на изменённые публичные модули. -- **Docstring readiness**: перед `pydocstyle`/`interrogate` привести публичные docstring'и к STYLEGUIDE: возвращаемая SDK-модель, nullable/empty behavior, per-operation overrides из канонического списка, идемпотентность, типовые исключения с metadata fields, поведение `dry_run=True` для write-методов. Для этого нужен отдельный `scripts/check_public_docstrings.py --output docstring-contract-report.json`: в PR 2 report-only, в PR 3/финальном DoD — strict gate для публичных символов, попадающих в generated reference. До этого `interrogate` может быть только report-only, а reference не считается финально полным. -- **README example sync**: README и tutorial/how-to snippets обязаны отражать реальные публичные сигнатуры текущего SDK. Если public method ушёл с `request=` DTO на flattened keyword-only параметры, старый пример не может жить как “иллюстративный”. Это отдельный docs-contract, проверяемый mktestdocs и review-чек-листом. +Пример: -## Структура `docs/site/` +```text +[SWAGGER_BINDING_NOT_FOUND] +Swagger operation has no SDK binding: +spec=Мессенджер.json +method=GET +path=/messenger/v1/accounts/{user_id}/chats/{chat_id} +``` +```text +[SWAGGER_BINDING_DUPLICATE] +Multiple SDK methods bind the same Swagger operation: +spec=Объявления.json +method=GET +path=/core/v1/items/{item_id} +methods: +- avito.ads.domain.Ad.get +- avito.ads.client.AdsClient.get_item ``` -docs/site/ - .pages # корневой nav для awesome-pages - index.md # hero + три роли-входа (P1/P2/P3) + Diátaxis-карта - tutorials/ - .pages - index.md - getting-started.md # pip install → get_self() — показывает from_env(); тест через harness conftest - first-promotion.md # placeholder PR 1; содержимое — PR 3 - how-to/ - .pages - index.md - auth-and-config.md # placeholder PR 1; содержимое в PR 3 - (остальные рецепты — PR 3) - reference/ - .pages - index.md - coverage.md # PR 1: «Покрытие API» со ссылками на GitHub blob URLs; заменяет битую ../avito/inventory.md - client.md # placeholder PR 1; полный reference включая debug_info() — PR 2b - operations.md # PR 2b: генерируемая карта operation → SDK method - config.md # PR 2b: AvitoSettings, AuthSettings, user_agent_suffix, env priority - domains/ # генерируется _gen_reference.py (не коммитится) - enums.md # PR 2b: генерируется из avito..__all__ - models.md # PR 2b - exceptions.md # PR 2b - pagination.md # PR 2b - testing.md # PR 2b - explanations/ - .pages - index.md - architecture.md # PR 1: placeholder с mermaid smoke-test; содержимое — PR 3 - security-and-redaction.md # PR 3: security-модель - (статьи — PR 3) - changelog.md # include из корневого CHANGELOG.md - assets/ - _gen_reference.py - overrides/ + +```text +[SWAGGER_ARG_UNKNOWN_QUERY_PARAM] +Binding references unknown query parameter: +method=avito.messenger.domain.Chat.list +expression=query.page +swagger_operation=GET /messenger/v1/accounts/{user_id}/chats +known_query_params=[limit, offset] ``` -## Генерация reference - -`docs/site/assets/_gen_reference.py`: - -1. Импортирует `scripts/parse_inventory.py` для получения `list[InventoryRow]`. -2. Обходит `avito/` исключая internals: `core/transport.py`, `core/retries.py`, `auth/provider.py`, `_env.py`, `__main__.py`. -3. Для каждого публичного пакета создаёт виртуальную страницу `reference/domains/.md` с шапкой (назначение пакета из inventory) и директивой `::: avito.`. Источник публичной поверхности для пакетной страницы — `__all__` экспортируемого пакета, а не простое сканирование всего дерева `avito//`. В отдельной таблице раздела перечисляются `Enum` этого домена с ссылками на `reference/enums.md`. -4. Создаёт виртуальную `reference/operations.md`: таблица `описание → HTTP method/path → пакет_sdk → доменный_объект → публичный_метод_sdk → deprecated/replacement`. Это основной discovery-индекс для P2. -5. Пишет виртуальный `reference/SUMMARY.md` для `literate-nav`. -6. Для операций с `deprecated: да` вставляет `!!! warning "Устаревшая операция"`. Ссылка на replacement добавляется только если поле `replacement` явно присутствует в `InventoryRow`. Эвристического вывода нет. -7. **Не пишет** в `inventory-coverage-report.json` — это ответственность `scripts/check_inventory_coverage.py`. -8. Создаёт виртуальную `reference/enums.md`: индекс всех публичных `Enum` из всех `avito..__all__`, разбитый по доменам, с директивой `::: avito..` для каждого. -9. На странице `reference/client.md` директивой `::: avito.AvitoClient.debug_info` отдельно раскрывает `debug_info()`; отсутствие символа — hard error генератора. - -Важно: `scripts/check_inventory_coverage.py` не должен сводиться к простому `hasattr`. Он проверяет связку `пакет_sdk + доменный_объект + публичный_метод_sdk`, special-case `AvitoClient.auth()`, legacy-домены и то, что публичный символ попадает в reference-индекс. Наличие метода без документируемого публичного пути считается gap. - -Все файлы создаются через `mkdocs_gen_files.open()` — **не на диск**, не в git. - -## Опции `mkdocstrings` - -```yaml -handlers: - python: - options: - docstring_style: google - docstring_section_style: table - show_signature_annotations: true - separate_signature: true - merge_init_into_class: true - show_source: false - filters: ["!^_"] - members_order: source - heading_level: 2 +## 10. Не Цель Декоратора + +Декоратор не должен: + +- генерировать SDK-код; +- валидировать реальные payload на runtime; +- выполнять HTTP; +- знать response statuses; +- знать schemas; +- заменять Swagger; +- заменять typed models. + +Runtime/request/response проверки делает `SwaggerFakeTransport` и contract tests, используя Swagger operation, найденную через binding. + +## Итоговый Инвариант + +```text +Swagger operation +↔ exactly one @swagger_operation SDK method +→ SwaggerFakeTransport validates actual HTTP request/response +→ contract tests validate all statuses and errors from Swagger ``` -## Разделение на этапы - -### PR 1 — Стабилизация существующего каркаса - -**Задача**: `mkdocs build --strict` проходит без предупреждений. Deploy проверяется после merge/push в main, потому что PR не публикует GitHub Pages. - -Конкретные изменения: - -- `mkdocs.yml`: удалить секцию `nav`. -- `mkdocs.yml`: верифицировать `pymdownx.superfences` с mermaid `custom_fence`; включить минимальную mermaid-диаграмму в `explanations/architecture.md` placeholder для смоук-теста рендера. -- `docs/site/.pages`: создать (см. раздел «Навигация»). -- `docs/site/index.md`: ссылка `../avito/inventory.md` → `reference/coverage.md`. -- `docs/site/reference/coverage.md`: страница «Покрытие API» с таблицей 23 Swagger-документов и GitHub blob URL-ами. -- `mkdocs.yml`, Poetry metadata, badges: синхронизировать canonical repo URL (`avito` vs `avito_python_api`) до добавления blob-ссылок. -- `docs/site/explanations/`: создать placeholder `architecture.md` с одной mermaid-блок-схемой, чтобы mermaid-рендер был протестирован в PR 1. -- `docs/site/explanations/.pages`: добавить `architecture.md`. -- `docs/site/reference/client.md`: placeholder. -- `docs/site/how-to/auth-and-config.md`: placeholder. -- `docs/site/how-to/.pages`: добавить `auth-and-config.md`. -- `docs/site/reference/.pages`: добавить `coverage.md`, `client.md`. -- `avito/testing/__init__.py`: синхронизировать публичный testing-export с `avito.testing.fake_transport.__all__`: `FakeTransport`, `FakeResponse`, `JsonValue`, `RecordedRequest`, `json_response`, `route_sequence`. Если `JsonValue` не должен быть публичным символом, сначала убрать его из обоих `__all__` и reference. - -**Критерий готовности PR 1**: `poetry install --with docs && poetry run mkdocs build --strict` проходит без предупреждений; mermaid-блок в `architecture.md` рендерится в `site/` без предупреждений strict-mode; после merge/push сайт деплоится на GitHub Pages с alias `latest`; TTFC-проверка tutorial проходит вручную. - -### PR 2a — Inventory, contract-скрипты - -Конкретные изменения: - -- `docs/avito/inventory.md`: добавить колонки `deprecated_since`, `replacement` и `removal_version` в таблицу операций; заполнить для всех `deprecated: да`. -- `scripts/parse_inventory.py`: реализовать с поддержкой новых колонок; возвращает `list[InventoryRow]` (frozen dataclass с полями `deprecated_since: str | None`, `replacement: str | None`, `removal_version: str | None`). -- `scripts/check_inventory_coverage.py --output `: реализовать; пишет `inventory-coverage-report.json`. Проверяет: каждой inventory-операции соответствует публичный SDK-символ; каждая `deprecated: да` запись имеет `deprecated_since`, `replacement` и `removal_version`; deprecation-период не меньше двух minor-релизов; описание и колонка `deprecated` не противоречат друг другу. В PR 2a работает report-only; hard `exit 1` включается в PR 3 / финальном DoD. -- `scripts/check_spec_inventory_sync.py --output `: реализовать; пишет `spec-inventory-report.json`. Проверяет: каждая операция из `docs/avito/api/*.json` присутствует в inventory; в inventory нет операций, отсутствующих в spec; совпадают `документ + метод + путь + раздел`. В сверке «документ + метод + путь + раздел» явно задокументировать имена колонок inventory, по которым берётся поле «раздел»; если имя расходится, переименование делается в этом же PR. В PR 2a работает report-only и публикуется как CI artifact. **Область охвата**: операция-уровень (method + path). Поле-уровневая сверка типов/nullability/enum-значений SDK-моделей с OpenAPI-схемами — отдельная SDK-задача вне scope docs-плана; §14.2 scorecard закрывается через ручной DA-аудит выборки из 20 моделей при оценке. -- `scripts/check_readme_domain_coverage.py`: реализовать; домены из inventory. -- `Makefile` — добавить в `docs-strict`: `poetry run python scripts/check_readme_domain_coverage.py`. -- CI: `inventory-coverage-report.json` и `spec-inventory-report.json` публикуются как артефакты. -- `poetry.lock`: обновить при добавлении зависимостей. - -**Критерий готовности PR 2a**: `scripts/parse_inventory.py` возвращает `list[InventoryRow]` с новыми колонками; колонки `deprecated_since`, `replacement`, `removal_version` заполнены; имя колонки `раздел` в `inventory.md` зафиксировано в `parse_inventory.py` как `section` (или эквивалент) и совпадает с тем, что читает `check_spec_inventory_sync.py`; `inventory-coverage-report.json` и `spec-inventory-report.json` публикуются как CI-артефакты (непустые gaps допустимы как report-only); `make docs-strict` проходит с `check_readme_domain_coverage.py`. - -### PR 2.5 — Runtime deprecation contract - -**Задача**: синхронизировать runtime-поведение SDK с deprecated-данными inventory. Это публичное SDK-изменение, поэтому оно отделено от генерации сайта. - -Конкретные изменения: - -- Добавить runtime `DeprecationWarning` для каждого публичного SDK-символа с `deprecated: да`, при первом вызове, с replacement и целевой версией удаления. -- Добавить/обновить docstring line у deprecated-символов: replacement и target removal version. -- Добавить `tests/contracts/test_deprecation_warnings.py`, который строит cases из inventory, но проверяет поведение через реальные публичные вызовы/минимальные fake-transport сценарии, а не только наличие атрибута. -- Добавить запись в `CHANGELOG.md` в секцию `Deprecated`; проверить, что CHANGELOG релиза содержит стандартные секции `Added`/`Changed`/`Deprecated`/`Removed`/`Fixed` (пустые секции допустимы только если политика changelog это явно разрешает). -- `scripts/check_changelog_sections.py --output changelog-sections-report.json`: проверяет, что CHANGELOG релиза содержит секции `Added`/`Changed`/`Deprecated`/`Removed`/`Fixed`; в PR 2.5 — report-only, в финальном DoD — strict. Закрывает scorecard §18.4. - -**Критерий готовности PR 2.5**: `pytest tests/contracts/test_deprecation_warnings.py` зелёный; runtime warnings не дублируются сверх первого вызова; `make test typecheck lint` зелёные; `changelog-sections-report.json` публикуется как CI artifact. - -### PR 2b — Reference: генерация, страницы, baselines - -**Prerequisite**: PR 2a и PR 2.5 слиты. PR 2a даёт `parse_inventory.py`; PR 2.5 обеспечивает runtime `DeprecationWarning`, без которого deprecated-admonition в reference преждевременен. - -Конкретные изменения: - -- `pyproject.toml`, группа `docs` — добавить и обновить `poetry.lock`: - ```toml - mkdocstrings = { version = ">=0.27", extras = ["python"] } - mkdocs-gen-files = ">=0.5" - mkdocs-literate-nav = ">=0.6" - ``` -- `mkdocs.yml`: подключить `gen-files`, `literate-nav`, `mkdocstrings[python]`. -- `docs/site/assets/_gen_reference.py`: реализовать (виртуальные файлы). -- `docs/site/reference/`: создать следующие страницы: - - `config.md` — документирует `AvitoSettings` и `AuthSettings`, включая `user_agent_suffix` (поле для суффикса User-Agent, STYLEGUIDE § User-Agent), env-переменные и priority-resolution (`env > .env > defaults`). - - `models.md`, `pagination.md`, `testing.md`; `operations.md` генерируется виртуально из inventory. - - `enums.md` — генерируется `_gen_reference.py` (виртуальный файл). - - `client.md` — полный reference включая `debug_info()` с документированным security-контрактом. - - `exceptions.md` — документирует поля `operation`, `status`, `request_id`, `attempt`, `method`, `endpoint` для каждого публичного исключения. -- `scripts/check_reference_public_surface.py --output `: реализовать; пишет `reference-public-report.json`. Проверяет: все экспорты из `avito.__all__`, `avito..__all__`, `avito.testing.__all__`, `avito.AvitoClient.debug_info` и все `Enum`-классы из `avito..__all__` (через `reference/enums.md`) попадают в reference; лишние internal/private символы не попадают в generated nav/discovery pages. В PR 2b работает report-only и публикуется как CI artifact. -- Docstring readiness audit: `scripts/check_public_docstrings.py --output ` проверяет шесть обязательных contract-аспектов каждого публичного метода: (1) возвращаемая SDK-модель, (2) nullable/empty behavior, (3) каждый поддерживаемый per-operation override из канонического списка `reference/config.md`, (4) идемпотентность, (5) типовые исключения (Raises) с перечислением метаданных, (6) поведение при `dry_run=True` для write-методов. В PR 2b report-only; строгий gate — в PR 3 / финальном DoD. Не блокирует PR 2b, но блокирует перевод `interrogate` в gate. -- `Makefile`: - ```makefile - docs-strict: - poetry run mkdocs build --strict - poetry run python scripts/check_readme_domain_coverage.py - - docs-check: docs-strict - lychee --exclude "avito\.ru" --retry-wait-time 5 --max-retries 3 --timeout 30 site/ - ``` -- `interrogate` — report-only: CI публикует артефакт `interrogate-report.txt`. Baseline коммитится в `.interrogate-baseline`: - ```json - {"modules": {"avito/accounts/client.py": 92.5, ...}, "generated_at": "", "interrogate_version": ""} - ``` - -**Критерий готовности PR 2b**: все семь пунктов STYLEGUIDE § What Constitutes the Public SDK Contract имеют reference-страницу с явным файловым маппингом в `docs-quality-report.json.public_contract_coverage`: `AvitoClient` → `client.md`, `AvitoSettings/AuthSettings` → `config.md`, resource factory methods → `operations.md` + `domains/*.md`, public models → `models.md` + `domains/*.md`, typed exceptions → `exceptions.md`, `PaginatedList` → `pagination.md`, `to_dict()`/`model_dump()` → `models.md`, `debug_info()` → `client.md`; `reference/config.md` документирует `user_agent_suffix` и priority-resolution; deprecated-бейджи рендерятся; `reference/operations.md` строится из inventory; `make docs-strict` проходит; `inventory-coverage-report.json`, `spec-inventory-report.json`, `reference-public-report.json` и `docstring-contract-report.json` публикуются как CI-артефакты; колонки `deprecated_since`/`replacement`/`removal_version` заполнены для всех `deprecated: да` записей. Непустые SDK coverage/spec-sync/reference-public/docstring gaps допустимы только как report-only артефакты PR 2b и должны быть закрыты к финальному DoD. - -### PR 3 — How-to, explanations и quality gates - -Конкретные изменения: - -- `docs/site/how-to/*` — 17 рецептов с фиксированными файлами и явным доменным покрытием: - `auth-and-config.md`, `chat-image-upload.md`, `promotion-dry-run.md`, `pagination.md`, - `order-labels.md`, `job-applications.md`, `autoteka-report.md`, `realty-booking.md`, - `cpa-calltracking.md`, `ratings-and-tariffs.md`, `per-operation-overrides.md`, - `idempotency.md`, `testing-with-fake-transport.md`, `diagnostics-and-logging.md`, - `security-practices.md` (сквозной документ: редакция секретов для consumer-кода, дополняет `explanations/security-and-redaction.md`), - `account-profile.md` (домен `accounts`: `get_self()`, баланс, иерархия), - `ad-listing-and-stats.md` (домен `ads`: листинг, статистика, пагинация объявлений). -- `docs/site/explanations/*` — концептуальные статьи с Mermaid: - `architecture.md`, `auth-flow.md`, `transport-and-retries.md`, `error-model.md`, - `pagination-semantics.md`, `dry-run-and-idempotency.md`, `testing-strategy.md`, - `api-coverage-and-deprecations.md`, - `config-resolution.md` (priority-resolution `env > .env > defaults`, детерминированность, scorecard §13.2), - `security-and-redaction.md` — security-модель и редакция секретов (покрывает scorecard §7.1–§7.4 через DA). -- `docs/site/tutorials/first-promotion.md`: написать содержимое (сейчас placeholder); tutorial `dry_run=True` → `dry_run=False` с mktestdocs harness. -- До массового написания how-to: обновить `README.md` и уже существующие tutorial-snippet'ы под реальные публичные сигнатуры текущего SDK. Устаревшие примеры с `request=` DTO там, где сигнатура уже flattened, переписываются, а не помечаются как “illustrative”. -- Перед `testing-with-fake-transport.md`: добавить публичный consumer-testing API. Выбранный контракт: `FakeTransport.as_client(*, user_id: int | None = None, retry_policy: RetryPolicy | None = None) -> AvitoClient`. Он создаёт полностью инициализированный `AvitoClient` поверх fake transport без post-init monkeypatch приватных полей и без публичного параметра `transport` в `AvitoClient.__init__`. `FakeTransport.build()` не используется в пользовательской документации; если он остаётся, он помечается как low-level/internal testing helper или проходит deprecation policy. -- Перед массовым написанием рецептов: реализовать mktestdocs harness на `getting-started.md` и одном how-to, прогнать `pytest tests/docs/`, затем масштабировать на остальные страницы. -- `tests/docs/conftest.py`: mktestdocs harness — monkeypatch `AvitoClient.from_env()` → lightweight docs-test facade поверх настоящих доменных объектов и `FakeTransport.build()`; заглушки env-переменных. Если how-to выполняют сетевые вызовы через прямой `AvitoClient(...)`, сначала принять отдельное публичное тестовое API для fake transport или переписать пример так, чтобы сетевой вызов выполнялся через `from_env()`. -- `tests/docs/test_docs_harness_surface.py`: проверяет, что docs-test facade не изобретает собственный API: имена фабрик/методов и callable-сигнатуры, используемые harness, совпадают с реальными публичными сигнатурами `AvitoClient` и соответствующих доменных объектов. -- `tests/docs/test_markdown_examples.py`: pytest-тест, который вызывает mktestdocs для `README.md`, `docs/site/tutorials/*.md` и `docs/site/how-to/*.md`; одного `conftest.py` недостаточно для запуска markdown-примеров. -- `tests/docs/test_no_placeholders.py`: падает, если production docs содержат `Раздел в разработке`, `placeholder`, `плейсхолдер`, `TODO`, `TBD`, `coming soon`. -- `scripts/check_docs_examples.py`: проверяет, что SDK-примеры в `reference/` и `explanations/` либо исполняются тем же collector'ом, либо не помечены как `python`/`pycon`. -- `pyproject.toml`, группа `docs` — добавить и обновить `poetry.lock`: - ```toml - mktestdocs = ">=0.2" - interrogate = ">=1.7" - pydocstyle = { version = ">=6.3", extras = ["toml"] } - ``` -- `mktestdocs` через `pytest tests/docs/`: все `python`/`pycon` блоки в README/tutorials/how-to. Включается в `make docs-strict`. -- `pydocstyle` с профилем Google — `make qa-docs`, **не** `make lint`. -- `interrogate` gate — diff против `origin/main`; per-module vs baseline. `ci.yml`: `fetch-depth: 0`. -- `CONTRIBUTING.md`: инструкция по установке lychee (`brew install lychee` / `cargo binstall lychee`); review-чек-лист README domain coverage. -- `.github/pull_request_template.md`: чек-лист coverage; добавить чек-бокс «Публичное переименование: alias сохранён + `DeprecationWarning` + запись в CHANGELOG Deprecated» для supporting-gate 18.5. -- `.github/workflows/docs.yml`: шаг `lycheeverse/lychee-action` (не вызов `make docs-check`); на `push` в `main` выполняются `mike deploy --push --update-aliases main latest` и `mike set-default --push latest`; на `push` тега `v*` выполняется `mike deploy --push --update-aliases stable`, где `` берётся из тега без `v`. -- `.github/workflows/ci.yml`: добавить `make docs-strict` в пайплайн; `fetch-depth: 0`; добавить `bandit -r avito/` как supporting-gate для 7.5 (report-only на этом этапе, strict — отдельная SDK-задача вне scope docs-плана). -- `Makefile`: в PR 3 расширить `docs-strict`, добавив `poetry run pytest tests/docs/`. В PR 2 snippet `docs-strict` ещё не включает mktestdocs. -- CI artifact `docs-quality-report.json`: воспроизводимый отчёт с фиксированной схемой (поля ниже обязательны; `null` = не выполнено). Без строгой схемы отчёт нельзя сравнивать между релизами и он не закрывает scorecard §28.1 (±5% между оценщиками): - ```json - { - "generated_at": "", - "sdk_version": "", - "diataxis_matrix": { - "tutorials": ["getting-started.md", "first-promotion.md"], - "how-to": ["auth-and-config.md", "...17 files total..."], - "reference": ["client.md", "config.md", "models.md", "..."], - "explanations": ["architecture.md", "...10 files total..."] - }, - "domain_howto_coverage": { - "accounts": "account-profile.md", - "ads": "ad-listing-and-stats.md", - "autoteka": "autoteka-report.md", - "...": "..." - }, - "public_contract_coverage": { - "AvitoClient": "client.md", - "AvitoSettings": "config.md", - "AuthSettings": "config.md", - "factory_methods": "operations.md", - "public_models": "models.md", - "typed_exceptions": "exceptions.md", - "PaginatedList": "pagination.md", - "serialization": "models.md", - "debug_info": "client.md" - }, - "disabled_criteria": ["12"], - "subcriteria": { - "15.1": {"grade": 1.0, "evidence": "getting-started.md проходит TT-процедуру"}, - "15.2": {"grade": 1.0, "evidence": "17 рецептов, все домены покрыты"}, - "15.3": {"grade": null, "evidence": "interrogate baseline + reference pages"}, - "15.4": {"grade": null, "evidence": "10 explanations"}, - "15.5": {"grade": null, "evidence": "CHANGELOG.md обновлён"}, - "15.6": {"grade": null, "evidence": "pytest tests/docs/ зелёный"} - }, - "supporting_gates": { - "7.3_debug_info_safe_by_default": null, - "7.5_bandit_high_severity": null, - "16.1_fake_transport_namespace": null, - "16.2_mock_contract_documented": null, - "16.3_json_serializable_models": null, - "16.4_context_manager_close": null, - "18.1_semver_compliant": null, - "18.2_deprecation_period_2minor": null, - "18.3_deprecation_warning_emitted": null, - "18.4_changelog_sections": null, - "18.5_public_renames_via_alias": null - }, - "ttfc_minutes": null, - "lychee_broken_links": 0, - "placeholder_count": 0, - "inventory_coverage_gaps": 0, - "spec_inventory_gaps": 0, - "reference_public_gaps": 0, - "docstring_contract_gaps": 0, - "reference_explanation_examples_gaps": 0, - "changelog_sections_gaps": 0 - } - ``` - -**Критерий готовности PR 3**: Diátaxis-матрица 4×N; каждый публичный домен (из inventory, кроме auth/core/testing) имеет ≥1 how-to с явным файловым маппингом в `domain_howto_coverage` поле `docs-quality-report.json` (включая `accounts` → `account-profile.md` и `ads` → `ad-listing-and-stats.md`); README/tutorials/how-to синхронизированы с реальными публичными сигнатурами SDK; все `python`/`pycon` блоки в README/tutorials/how-to исполняются через mktestdocs с harness conftest; docs-harness surface проверен отдельным тестом; SDK-примеры в reference/explanations либо исполняются, либо не помечены как executable; `make docs-strict`, `make qa-docs` и CI lychee-step проходят; `mike list` показывает как минимум `main [latest]` и текущий релиз `[stable]`; scorecard §15.1–15.6 закрыт по каждому подпункту; все `supporting_gates.*` заполнены `grade` и `evidence` (`null` запрещён); `public_contract_coverage` заполнено всеми девятью ключами; `disabled_criteria` содержит `["12"]` с пояснением в release notes. - -## Риски и их нейтрализация - -| Риск | Нейтрализация | -|---|---| -| mktestdocs падает на `AvitoClient.from_env()` в CI | `tests/docs/conftest.py` monkeypatches from_env → docs-test facade поверх FakeTransport; реальных API-вызовов нет | -| mktestdocs пропускает прямой `AvitoClient(...)` и уходит в сеть | Использовать зафиксированный контракт: executable network calls только через `from_env()`, consumer-testing через `FakeTransport.as_client()`, прямой `AvitoClient(...)` без transport-вызова | -| README содержит SDK-snippet'ы, которые не покрыты docs-harness | Включить `README.md` в `tests/docs/test_markdown_examples.py`; переписать или переклассифицировать каждый non-executable блок | -| Annotation-маркеры `# (N)!` ломают Python-блоки | Правило: в tutorials/how-to нет аннотационного синтаксиса; `content.code.annotate` остаётся глобально включённым (это не источник проблемы) | -| `coverage.md` ссылается на файлы вне `docs_dir` | Только GitHub blob URLs; нет относительных ссылок на `docs/avito/` | -| Inventory расходится со Swagger/OpenAPI-спеками | `check_spec_inventory_sync.py` сравнивает `docs/avito/api/*.json` с `inventory.md`; report-only в PR 2, strict в финальном DoD | -| lychee не установлен локально | `make docs-strict` без lychee; `make docs-check` документирует зависимость; в CI — GitHub Action | -| Финальный DoD по deprecated недостижим без inventory | В scope PR 2: добавить колонки `deprecated_since`/`replacement`/`removal_version`; финальный DoD применяется только после их заполнения | -| Inventory содержит противоречивые deprecated-данные | `check_inventory_coverage.py` проверяет `description` vs `deprecated`, обязательные `deprecated_since`/`replacement`/`removal_version` и deprecation-период | -| Runtime deprecated warnings смешиваются с docs-задачей | Выделить PR 2.5 SDK-contract: warnings, docstrings, tests, CHANGELOG | -| `_gen_reference.py` становится владельцем contract-логики | `check_inventory_coverage.py` владеет отчётом; генератор только рендерит | -| `check_inventory_coverage.py` превращается в `hasattr`-проверку | Проверять связку из inventory, special-case auth/legacy и попадание символа в reference-индекс | -| Generated reference случайно протекает internal/private surface | `check_reference_public_surface.py` сверяет reference с `__all__`-экспортами и top-level contract | -| `poetry.lock` устаревает | Каждый PR с новыми deps коммитит обновлённый lock (`poetry lock` для Poetry 2.x) | -| interrogate diff требует git history | `fetch-depth: 0` в ci.yml (PR 3) | -| lychee шумит на нестабильных хостах | `--exclude "avito\.ru"`, retry 3, timeout 30с | -| `repo_url` расходится с GitHub Pages/coverage badge | В PR 1 выбрать canonical repo и синхронизировать `mkdocs.yml`, Poetry metadata, badges и blob-ссылки | -| В production docs остаются плейсхолдеры | `tests/docs/test_no_placeholders.py` и финальный `rg`-gate на `Раздел в разработке|placeholder|плейсхолдер|TODO|TBD|coming soon` | -| README/snippet'ы отстают от реальных public signatures | Обязательная синхронизация примеров в PR 3 + mktestdocs + review-чек-лист | -| Docs-harness начинает жить отдельно от реального API | `tests/docs/test_docs_harness_surface.py` сверяет facade с `AvitoClient` и доменными public methods | -| Harness не покрывает новый endpoint из how-to: `FakeTransport._handle` падает без понятного сообщения | Добавить в harness-fallback сообщение «маршрут не прописан в conftest, добавь route_sequence для »; каждый новый how-to Python-блок проверяется в `pytest tests/docs/` до merge | -| Поле-уровневая сверка типов/nullability/enum SDK-моделей с OpenAPI-схемами отсутствует в скриптах | Явно исключить из scope: `check_spec_inventory_sync.py` покрывает только operation-уровень; §14.2 scorecard закрывается ручным DA-аудитом выборки 20 моделей при финальной оценке; полноценный `check_spec_model_sync.py` — отдельная SDK-задача | -| `debug_info()` не попадает в reference — STYLEGUIDE-нарушение | `_gen_reference.py` hard error при отсутствии символа; `check_reference_public_surface.py` strict в финальном DoD | -| Enum-контракт теряется: новый enum в `avito..enums.py` не попал в `__all__` | `check_reference_public_surface.py` сверяет `reference/enums.md` со всеми `Enum` подклассами во всех публичных доменных пакетах | -| Exception metadata не документированы — scorecard §6.3 провал | `check_public_docstrings.py` шестой аспект (Raises + metadata fields); `reference/exceptions.md` strict gate | -| Per-operation overrides описаны неконсистентно между методами | Канонический набор в `reference/config.md`; `check_public_docstrings.py` сверяет | -| Mermaid не рендерится в strict-mode | Смоук-тест mermaid в PR 1 на `explanations/architecture.md` placeholder | -| Scorecard §12 искажает финальный Score, т.к. SDK sync | `disabled_criteria: ["12"]` + перераспределение весов зафиксировано в отчёте | -| `supporting_gates.*` остаются `null` и DoD пройден формально | DoD запрещает `null`; PR 3 критерий готовности требует `grade` + `evidence` для каждого | -| PR 2b мержится раньше PR 2.5 — admonition без runtime warning | PR 2.5 prerequisite для PR 2b; зафиксировано в «Зафиксированных решениях» | - -## Реиспользуемые артефакты - -- `avito/testing/__init__.py` (после PR 1: `FakeTransport`, `FakeResponse`, `JsonValue`, `RecordedRequest`, `json_response`, `route_sequence`; после PR 3: `FakeTransport.as_client()`) — harness conftest, how-to, reference. -- `avito/core/exceptions.py` поля `operation`, `status`, `request_id`, `attempt`, `method`, `endpoint` — документируются в reference `exceptions.md` и explanation `error-model.md`. -- `avito//enums.py` (все домены) — `reference/enums.md` (генерируется). -- `AvitoClient.debug_info` — `reference/client.md`, `explanations/security-and-redaction.md`. -- `avito/core/pagination.py:PaginatedList` — reference `pagination.md`, explanation `pagination-semantics.md`. -- `avito/core/serialization.py:SerializableModel` — reference `models.md`. -- `docs/avito/inventory.md` — парсится через `parse_inventory.py`; источник доменов, deprecated-статусов, `deprecated_since`, `replacement`, `removal_version`. -- Доменные `client.py` с публичными docstring'ами — автопарсятся `mkdocstrings`; до финального DoD они проходят docstring readiness audit по STYLEGUIDE. -- `CHANGELOG.md` — включается через `mkdocs-include-markdown-plugin`. - -## Definition of Done (итоговая) - -- Все PR 1, PR 2a, PR 2.5, PR 2b и PR 3 смержены. -- `mkdocs build --strict` — без предупреждений. -- CI lychee-step — ноль битых ссылок. -- Все `python`/`pycon` блоки в README/tutorials/how-to исполняются через mktestdocs с harness conftest в `pytest tests/docs/`. -- SDK-примеры в reference/explanations либо исполняются тем же collector'ом, либо не помечены как executable Python. -- `interrogate` baseline зафиксирован; gate проходит для изменённых публичных модулей. -- `make qa-docs` зелёный после закрытия docstring readiness gaps. -- Deprecated-статусы в reference совпадают с inventory; runtime `DeprecationWarning` реализован для deprecated SDK-символов; `test_deprecation_warnings` зелёный; `deprecated_since`, `replacement` и `removal_version` заполнены для всех `deprecated: да` записей; deprecation-период не меньше двух minor-релизов. -- `inventory-coverage-report.json` пуст. -- `spec-inventory-report.json` пуст. -- `reference-public-report.json` пуст. -- `docs-quality-report` опубликован как CI artifact и показывает 15.1–15.6 без пропусков, а также supporting-gates для scorecard §16 и §18. -- Все supporting-gates в `docs-quality-report.json` имеют `grade` и `evidence` (запрет `null`). -- `public_contract_coverage` заполнено по всем девяти пунктам STYLEGUIDE § What Constitutes the Public SDK Contract. -- `disabled_criteria` содержит `["12"]` с обоснованием. -- `reference/enums.md` сгенерирован и покрывает все публичные `Enum` из `avito..__all__`. -- `reference/exceptions.md` документирует все поля exception metadata (`operation`, `status`, `request_id`, `attempt`, `method`, `endpoint`) для каждого публичного исключения. -- `reference/client.md` документирует `debug_info()` с security-контрактом. -- `explanations/security-and-redaction.md` опубликован. -- `changelog-sections-report.json` пуст (CHANGELOG релиза содержит все пять секций). -- `reference-explanation-examples-report.json` пуст. -- TTFC измерен вручную перед финальным DoD и зафиксирован в `ttfc_minutes`. -- `bandit -r avito/` отчёт опубликован как CI artifact (supporting-gate 7.5). -- Review-чек-лист в `.github/pull_request_template.md` содержит пункт про alias-переименование. -- Docstring readiness gaps закрыты для публичных контрактов, попадающих в generated reference. -- Diátaxis-матрица 4×N; каждый публичный домен (кроме auth/core/testing) имеет ≥1 how-to с явным маппингом в `docs-quality-report.json`; `make docs-strict` проходит полностью. -- Reference `operations.md` даёт карту всех inventory operations к публичным SDK-методам. -- Reference `testing.md` и how-to `testing-with-fake-transport.md` покрывают все аспекты public testing contract: scripting responses, call inspection, transport-level errors, `Retry-After`, `as_client()` consumer test. -- Все семь пунктов STYLEGUIDE § What Constitutes the Public SDK Contract имеют reference-страницу, явный файловый маппинг зафиксирован в `docs-quality-report.json.public_contract_coverage`. -- README/tutorials/how-to snippet'ы соответствуют актуальным публичным сигнатурам SDK; устаревших примеров с pre-refactor `request=` DTO в flattened-methods не осталось. -- В production docs нет плейсхолдеров: `Раздел в разработке`, `placeholder`, `плейсхолдер`, `TODO`, `TBD`, `coming soon`. -- `mike list` показывает `main [latest]` и как минимум один релизный docs-version с alias `stable`; root redirect ведёт на `latest`. - -## Verification - -1. `poetry install --with docs` — зависимости встают, lock актуален. -2. `make docs-serve` — локальный сайт, четыре Diátaxis-вкладки. -3. `make docs-strict` — после PR 2: `mkdocs build --strict` + `check_readme_domain_coverage.py`; после PR 3 дополнительно mktestdocs через `pytest tests/docs/` и placeholder-gate. -4. `make docs-check` — дополнительно lychee (требует `brew install lychee`). -5. `make qa-docs` — `pydocstyle` с профилем Google. -6. `poetry run pytest tests/docs/` — исполняет README/tutorials/how-to snippets и проверяет отсутствие плейсхолдеров. -7. TTFC-процедура (runbook в `CONTRIBUTING.md`): чистый venv, `pip install avito-py`, tutorial, секундомер до реального `get_self()` с настоящими ключами; результат пишется в `ttfc_minutes` текущего `docs-quality-report.json`. Выполняется ответственным мейнтейнером перед финальным DoD. -8. `pytest tests/contracts/test_deprecation_warnings.py` — для каждого SDK-символа с `deprecated: да`. -9. `python scripts/check_inventory_coverage.py --strict --output inventory-coverage-report.json` — exit 0. -10. `python scripts/check_spec_inventory_sync.py --strict --output spec-inventory-report.json` — exit 0. -11. `python scripts/check_reference_public_surface.py --strict --output reference-public-report.json` — exit 0. -12. `python scripts/check_public_docstrings.py --strict --output docstring-contract-report.json` — exit 0 после закрытия gaps. -13. `python scripts/check_changelog_sections.py --strict --output changelog-sections-report.json` — exit 0. -14. `python scripts/check_docs_examples.py --strict --output reference-explanation-examples-report.json` — exit 0. -15. `bandit -r avito/` — supporting-gate 7.5, отчёт без high-severity в публичных модулях. -16. `mkdocs build --strict` рендерит mermaid в `architecture.md` без предупреждений. -17. `rg -n "Раздел в разработке|placeholder|плейсхолдер|TODO|TBD|coming soon" docs/site README.md` — пустой вывод для production docs. -18. CI: PR с битой ссылкой → lychee-step падает; PR с пониженным coverage → interrogate падает. -19. Push в `main` → `mike deploy --push --update-aliases main latest` + `mike set-default --push latest`; push тега `v*` → `mike deploy --push --update-aliases stable`; `mike list` показывает оба alias. +Декоратор дает строгую адресацию. Линтер гарантирует, что адресация полная и валидная. Swagger остается единственным источником API-контракта.