From 114bbffbdbe6006f1a28f41ea676bbcdd88fbe98 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Tue, 28 Apr 2026 14:38:10 +0300 Subject: [PATCH 01/15] =?UTF-8?q?=D0=A1=D0=BA=D1=80=D0=B8=D0=BF=D1=82=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=81=D0=BF=D0=B5=D1=86=D0=B8=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=D1=86=D0=B8=D0=B8=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 14 +- avito/ads/client.py | 72 +++-- avito/ads/mappers.py | 6 +- avito/client.py | 18 +- avito/core/pagination.py | 15 ++ avito/core/types.py | 4 + avito/summary/models.py | 5 +- scripts/fetch_swagger_diff.py | 318 +++++++++++++++++++++++ tests/contracts/test_client_contracts.py | 37 +++ tests/domains/ads/test_ads.py | 48 ++-- 10 files changed, 500 insertions(+), 37 deletions(-) create mode 100644 scripts/fetch_swagger_diff.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d5287d6..209cfdc 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -35,7 +35,19 @@ "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\")" ] } } diff --git a/avito/ads/client.py b/avito/ads/client.py index acfad1f..d03eceb 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( diff --git a/avito/ads/mappers.py b/avito/ads/mappers.py index cec4ce9..2757100 100644 --- a/avito/ads/mappers.py +++ b/avito/ads/mappers.py @@ -176,7 +176,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: diff --git a/avito/client.py b/avito/client.py index c6ad99f..a53c94d 100644 --- a/avito/client.py +++ b/avito/client.py @@ -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), 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/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/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/scripts/fetch_swagger_diff.py b/scripts/fetch_swagger_diff.py new file mode 100644 index 0000000..0afa23c --- /dev/null +++ b/scripts/fetch_swagger_diff.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path + +import httpx + +ROOT = Path(__file__).resolve().parents[1] +SPEC_DIR = ROOT / "docs" / "avito" / "api" +HTTP_METHODS = {"get", "post", "put", "delete", "patch"} +LIST_URL = "https://developers.avito.ru/web/1/openapi/list" +INFO_URL = "https://developers.avito.ru/web/1/openapi/info/{slug}" + + +def normalize_title(value: str) -> str: + return re.sub(r"[^A-Za-zА-Яа-яЁё0-9]", "", value).casefold() + + +@dataclass(slots=True, frozen=True) +class Operation: + method: str + path: str + + def __str__(self) -> str: + return f"{self.method:<6} {self.path}" + + +@dataclass(slots=True) +class ApiEntry: + slug: str + title: str + spec: dict[str, object] + + @property + def norm_key(self) -> str: + info_title = str(self.spec.get("info", {}).get("title", self.title)) # type: ignore[union-attr] + return normalize_title(info_title) + + def operations(self) -> set[Operation]: + result: set[Operation] = set() + paths = self.spec.get("paths", {}) + if not isinstance(paths, dict): + return result + for path, path_item in paths.items(): + if not isinstance(path_item, dict): + continue + for method in path_item: + if method.lower() in HTTP_METHODS: + result.add(Operation(method.upper(), path)) + return result + + +@dataclass(slots=True) +class LocalFile: + path: Path + + @property + def norm_key(self) -> str: + return normalize_title(self.path.stem) + + def spec(self) -> dict[str, object]: + return json.loads(self.path.read_text(encoding="utf-8")) # type: ignore[return-value] + + def operations(self) -> set[Operation]: + result: set[Operation] = set() + paths = self.spec().get("paths", {}) + if not isinstance(paths, dict): + return result + for path, path_item in paths.items(): + if not isinstance(path_item, dict): + continue + for method in path_item: + if method.lower() in HTTP_METHODS: + result.add(Operation(method.upper(), path)) + return result + + +@dataclass(slots=True) +class SectionDiff: + slug: str + title: str + filename: str + added: list[Operation] = field(default_factory=list) + removed: list[Operation] = field(default_factory=list) + + @property + def has_changes(self) -> bool: + return bool(self.added or self.removed) + + +@dataclass(slots=True) +class DiffResult: + new_apis: list[ApiEntry] = field(default_factory=list) + removed_apis: list[LocalFile] = field(default_factory=list) + changed: list[SectionDiff] = field(default_factory=list) + + @property + def total_added(self) -> int: + return sum(len(s.added) for s in self.changed) + + @property + def total_removed(self) -> int: + return sum(len(s.removed) for s in self.changed) + + @property + def has_any_changes(self) -> bool: + return bool(self.new_apis or self.removed_apis or any(s.has_changes for s in self.changed)) + + +def fetch_catalog(client: httpx.Client) -> list[dict[str, str]]: + resp = client.get(LIST_URL) + resp.raise_for_status() + return resp.json() # type: ignore[no-any-return] + + +def fetch_spec(client: httpx.Client, slug: str) -> dict[str, object]: + url = INFO_URL.format(slug=slug) + resp = client.get(url) + resp.raise_for_status() + payload = resp.json() + return json.loads(payload["swagger"]) # type: ignore[no-any-return] + + +def load_local_files() -> dict[str, LocalFile]: + result: dict[str, LocalFile] = {} + for path in sorted(SPEC_DIR.glob("*.json")): + local = LocalFile(path) + result[local.norm_key] = local + return result + + +def _find_local_key(entry_key: str, local_files: dict[str, LocalFile]) -> str | None: + if entry_key in local_files: + return entry_key + # Fuzzy prefix match handles titles that gained/lost suffixes like "(beta-version)". + for local_key in local_files: + if entry_key.startswith(local_key) or local_key.startswith(entry_key): + return local_key + return None + + +def compute_diff(remote_entries: list[ApiEntry], local_files: dict[str, LocalFile]) -> DiffResult: + diff = DiffResult() + matched_local_keys: set[str] = set() + + for entry in remote_entries: + key = entry.norm_key + matched_key = _find_local_key(key, local_files) + if matched_key is None: + diff.new_apis.append(entry) + continue + + local = local_files[matched_key] + matched_local_keys.add(matched_key) + + remote_ops = entry.operations() + local_ops = local.operations() + added = sorted(remote_ops - local_ops, key=lambda op: (op.path, op.method)) + removed = sorted(local_ops - remote_ops, key=lambda op: (op.path, op.method)) + + section = SectionDiff( + slug=entry.slug, + title=entry.title, + filename=local.path.name, + added=added, + removed=removed, + ) + diff.changed.append(section) + + for key, local in local_files.items(): + if key not in matched_local_keys: + diff.removed_apis.append(local) + + diff.changed.sort(key=lambda s: s.title) + return diff + + +def print_diff(diff: DiffResult) -> None: + if not diff.has_any_changes: + print("Изменений не найдено.") + return + + if diff.new_apis: + print("=== Новые API-разделы ===") + for entry in sorted(diff.new_apis, key=lambda e: e.title): + print(f" + {entry.slug:<30} {entry.title}") + print() + + if diff.removed_apis: + print("=== Удалённые API-разделы ===") + for local in sorted(diff.removed_apis, key=lambda f: f.path.name): + print(f" - {local.path.name}") + print() + + changed_sections = [s for s in diff.changed if s.has_changes] + if changed_sections: + print("=== Изменения в существующих разделах ===") + for section in changed_sections: + print(f"\n [{section.title}] ({section.filename})") + for op in section.added: + print(f" + {op}") + for op in section.removed: + print(f" - {op}") + print() + + sections_changed = len(changed_sections) + print( + f"Итог: {sections_changed} раздел(ов) изменено, " + f"{diff.total_added} операций добавлено, " + f"{diff.total_removed} удалено." + ) + if diff.new_apis: + print(f" Новых API-разделов: {len(diff.new_apis)}") + if diff.removed_apis: + print(f" Удалённых API-разделов: {len(diff.removed_apis)}") + + +def build_json_report(diff: DiffResult) -> dict[str, object]: + return { + "new_apis": [{"slug": e.slug, "title": e.title} for e in diff.new_apis], + "removed_apis": [f.path.name for f in diff.removed_apis], + "changed": [ + { + "slug": s.slug, + "title": s.title, + "filename": s.filename, + "added": [{"method": op.method, "path": op.path} for op in s.added], + "removed": [{"method": op.method, "path": op.path} for op in s.removed], + } + for s in diff.changed + if s.has_changes + ], + "summary": { + "new_api_count": len(diff.new_apis), + "removed_api_count": len(diff.removed_apis), + "changed_section_count": sum(1 for s in diff.changed if s.has_changes), + "total_added_ops": diff.total_added, + "total_removed_ops": diff.total_removed, + }, + } + + +def update_files(remote_entries: list[ApiEntry], local_files: dict[str, LocalFile]) -> None: + updated = 0 + created = 0 + for entry in remote_entries: + key = entry.norm_key + if key in local_files: + target = local_files[key].path + target.write_text( + json.dumps(entry.spec, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + updated += 1 + else: + info_title = str(entry.spec.get("info", {}).get("title", entry.title)) # type: ignore[union-attr] + filename = re.sub(r"[^A-Za-zА-Яа-яЁё0-9\[\]\-]", "", info_title) + ".json" + target = SPEC_DIR / filename + target.write_text( + json.dumps(entry.spec, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + created += 1 + print(f" Создан: {filename}", file=sys.stderr) + print(f"Обновлено: {updated}, создано: {created} файлов.", file=sys.stderr) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Скачать swagger-спецификации с developers.avito.ru и показать diff с docs/avito/api/." + ) + parser.add_argument( + "--update", + action="store_true", + help="Перезаписать файлы в docs/avito/api/ актуальными версиями.", + ) + parser.add_argument( + "--output", + type=Path, + metavar="PATH", + help="Сохранить машиночитаемый diff в JSON-файл.", + ) + args = parser.parse_args() + + print("Загружаю список API...", file=sys.stderr) + with httpx.Client(timeout=30) as client: + catalog = fetch_catalog(client) + remote_entries: list[ApiEntry] = [] + for item in catalog: + slug = item["slug"] + title = item.get("title", slug) + print(f" Скачиваю {slug} ({title})...", file=sys.stderr) + spec = fetch_spec(client, slug) + remote_entries.append(ApiEntry(slug=slug, title=title, spec=spec)) + + print(f"Скачано {len(remote_entries)} спецификаций.", file=sys.stderr) + + local_files = load_local_files() + diff = compute_diff(remote_entries, local_files) + + print_diff(diff) + + if args.output: + report = build_json_report(diff) + args.output.write_text( + json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + print(f"\nJSON-отчёт сохранён: {args.output}", file=sys.stderr) + + if args.update: + print("\nОбновляю файлы...", file=sys.stderr) + update_files(remote_entries, local_files) + + +if __name__ == "__main__": + main() 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/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: From f403dcfcefbe144f22ba5ff2546a9d229e466ef7 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Tue, 28 Apr 2026 20:41:06 +0300 Subject: [PATCH 02/15] =?UTF-8?q?=D0=9F=D0=BE=D0=BA=D1=80=D1=8B=D1=82?= =?UTF-8?q?=D0=B8=D0=B5=20enum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- avito/ads/__init__.py | 13 ++- avito/ads/enums.py | 84 +++++++++++++++ avito/ads/mappers.py | 24 ++++- avito/ads/models.py | 14 ++- avito/autoteka/enums.py | 4 + avito/client.py | 25 +++-- avito/core/rate_limit.py | 99 +++++++++++++++++ avito/core/retries.py | 29 ++++- avito/core/transport.py | 67 +++++++++++- avito/cpa/__init__.py | 2 + avito/cpa/enums.py | 16 ++- avito/cpa/mappers.py | 13 ++- avito/cpa/models.py | 4 +- avito/jobs/__init__.py | 12 ++- avito/jobs/enums.py | 46 +++++++- avito/jobs/mappers.py | 22 ++-- avito/jobs/models.py | 8 +- avito/messenger/__init__.py | 2 + avito/messenger/enums.py | 13 +++ avito/messenger/mappers.py | 6 +- avito/messenger/models.py | 4 +- avito/orders/__init__.py | 6 ++ avito/orders/enums.py | 79 +++++++++++++- avito/orders/mappers.py | 20 ++-- avito/orders/models.py | 10 +- avito/promotion/__init__.py | 4 + avito/promotion/enums.py | 37 +++++++ avito/promotion/mappers.py | 18 ++-- avito/promotion/models.py | 10 +- avito/ratings/__init__.py | 3 +- avito/ratings/enums.py | 14 ++- avito/ratings/mappers.py | 7 +- avito/ratings/models.py | 3 +- avito/realty/__init__.py | 4 +- avito/realty/enums.py | 20 +++- avito/realty/mappers.py | 10 +- avito/realty/models.py | 7 +- .../explanations/transport-and-retries.md | 4 +- docs/site/reference/config.md | 3 + pyproject.toml | 4 + tests/core/test_configuration.py | 6 ++ tests/core/test_transport.py | 102 ++++++++++++++++++ tests/domains/promotion/test_promotion.py | 12 ++- 44 files changed, 811 insertions(+), 82 deletions(-) create mode 100644 avito/core/rate_limit.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 209cfdc..2d04348 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -47,7 +47,8 @@ "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(curl -s --max-time 10 \"https://developers.avito.ru/api-catalog/messenger/swagger.json\")", + "Bash(awk -F'|' '{print $15}')" ] } } 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/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 2757100..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, @@ -423,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/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 a53c94d..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 ( @@ -414,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/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/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/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/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/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/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/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/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/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..419635f 100644 --- a/avito/ratings/models.py +++ b/avito/ratings/models.py @@ -5,7 +5,7 @@ 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) @@ -67,6 +67,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/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/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/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/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/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_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/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 = [ From d3ffd2b5c4bca113838f7d130418a72525df5701 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Tue, 28 Apr 2026 21:32:24 +0300 Subject: [PATCH 03/15] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=BB=D0=B8=D1=88=D0=BD=D0=B5=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/build_docs_quality_report.py | 388 ---------------------- scripts/check_changelog_sections.py | 64 ---- scripts/check_docs_examples.py | 151 --------- scripts/check_interrogate_gate.py | 114 ------- scripts/check_inventory_coverage.py | 106 ------ scripts/check_public_docstrings.py | 155 --------- scripts/check_readme_domain_coverage.py | 62 ---- scripts/check_reference_public_surface.py | 140 -------- scripts/check_spec_inventory_sync.py | 131 -------- scripts/fetch_swagger_diff.py | 318 ------------------ scripts/parse_inventory.py | 182 ---------- scripts/public_sdk_surface.py | 241 -------------- 12 files changed, 2052 deletions(-) delete mode 100644 scripts/build_docs_quality_report.py delete mode 100644 scripts/check_changelog_sections.py delete mode 100644 scripts/check_docs_examples.py delete mode 100644 scripts/check_interrogate_gate.py delete mode 100644 scripts/check_inventory_coverage.py delete mode 100644 scripts/check_public_docstrings.py delete mode 100644 scripts/check_readme_domain_coverage.py delete mode 100644 scripts/check_reference_public_surface.py delete mode 100644 scripts/check_spec_inventory_sync.py delete mode 100644 scripts/fetch_swagger_diff.py delete mode 100644 scripts/parse_inventory.py delete mode 100644 scripts/public_sdk_surface.py 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/fetch_swagger_diff.py b/scripts/fetch_swagger_diff.py deleted file mode 100644 index 0afa23c..0000000 --- a/scripts/fetch_swagger_diff.py +++ /dev/null @@ -1,318 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import re -import sys -from dataclasses import dataclass, field -from pathlib import Path - -import httpx - -ROOT = Path(__file__).resolve().parents[1] -SPEC_DIR = ROOT / "docs" / "avito" / "api" -HTTP_METHODS = {"get", "post", "put", "delete", "patch"} -LIST_URL = "https://developers.avito.ru/web/1/openapi/list" -INFO_URL = "https://developers.avito.ru/web/1/openapi/info/{slug}" - - -def normalize_title(value: str) -> str: - return re.sub(r"[^A-Za-zА-Яа-яЁё0-9]", "", value).casefold() - - -@dataclass(slots=True, frozen=True) -class Operation: - method: str - path: str - - def __str__(self) -> str: - return f"{self.method:<6} {self.path}" - - -@dataclass(slots=True) -class ApiEntry: - slug: str - title: str - spec: dict[str, object] - - @property - def norm_key(self) -> str: - info_title = str(self.spec.get("info", {}).get("title", self.title)) # type: ignore[union-attr] - return normalize_title(info_title) - - def operations(self) -> set[Operation]: - result: set[Operation] = set() - paths = self.spec.get("paths", {}) - if not isinstance(paths, dict): - return result - for path, path_item in paths.items(): - if not isinstance(path_item, dict): - continue - for method in path_item: - if method.lower() in HTTP_METHODS: - result.add(Operation(method.upper(), path)) - return result - - -@dataclass(slots=True) -class LocalFile: - path: Path - - @property - def norm_key(self) -> str: - return normalize_title(self.path.stem) - - def spec(self) -> dict[str, object]: - return json.loads(self.path.read_text(encoding="utf-8")) # type: ignore[return-value] - - def operations(self) -> set[Operation]: - result: set[Operation] = set() - paths = self.spec().get("paths", {}) - if not isinstance(paths, dict): - return result - for path, path_item in paths.items(): - if not isinstance(path_item, dict): - continue - for method in path_item: - if method.lower() in HTTP_METHODS: - result.add(Operation(method.upper(), path)) - return result - - -@dataclass(slots=True) -class SectionDiff: - slug: str - title: str - filename: str - added: list[Operation] = field(default_factory=list) - removed: list[Operation] = field(default_factory=list) - - @property - def has_changes(self) -> bool: - return bool(self.added or self.removed) - - -@dataclass(slots=True) -class DiffResult: - new_apis: list[ApiEntry] = field(default_factory=list) - removed_apis: list[LocalFile] = field(default_factory=list) - changed: list[SectionDiff] = field(default_factory=list) - - @property - def total_added(self) -> int: - return sum(len(s.added) for s in self.changed) - - @property - def total_removed(self) -> int: - return sum(len(s.removed) for s in self.changed) - - @property - def has_any_changes(self) -> bool: - return bool(self.new_apis or self.removed_apis or any(s.has_changes for s in self.changed)) - - -def fetch_catalog(client: httpx.Client) -> list[dict[str, str]]: - resp = client.get(LIST_URL) - resp.raise_for_status() - return resp.json() # type: ignore[no-any-return] - - -def fetch_spec(client: httpx.Client, slug: str) -> dict[str, object]: - url = INFO_URL.format(slug=slug) - resp = client.get(url) - resp.raise_for_status() - payload = resp.json() - return json.loads(payload["swagger"]) # type: ignore[no-any-return] - - -def load_local_files() -> dict[str, LocalFile]: - result: dict[str, LocalFile] = {} - for path in sorted(SPEC_DIR.glob("*.json")): - local = LocalFile(path) - result[local.norm_key] = local - return result - - -def _find_local_key(entry_key: str, local_files: dict[str, LocalFile]) -> str | None: - if entry_key in local_files: - return entry_key - # Fuzzy prefix match handles titles that gained/lost suffixes like "(beta-version)". - for local_key in local_files: - if entry_key.startswith(local_key) or local_key.startswith(entry_key): - return local_key - return None - - -def compute_diff(remote_entries: list[ApiEntry], local_files: dict[str, LocalFile]) -> DiffResult: - diff = DiffResult() - matched_local_keys: set[str] = set() - - for entry in remote_entries: - key = entry.norm_key - matched_key = _find_local_key(key, local_files) - if matched_key is None: - diff.new_apis.append(entry) - continue - - local = local_files[matched_key] - matched_local_keys.add(matched_key) - - remote_ops = entry.operations() - local_ops = local.operations() - added = sorted(remote_ops - local_ops, key=lambda op: (op.path, op.method)) - removed = sorted(local_ops - remote_ops, key=lambda op: (op.path, op.method)) - - section = SectionDiff( - slug=entry.slug, - title=entry.title, - filename=local.path.name, - added=added, - removed=removed, - ) - diff.changed.append(section) - - for key, local in local_files.items(): - if key not in matched_local_keys: - diff.removed_apis.append(local) - - diff.changed.sort(key=lambda s: s.title) - return diff - - -def print_diff(diff: DiffResult) -> None: - if not diff.has_any_changes: - print("Изменений не найдено.") - return - - if diff.new_apis: - print("=== Новые API-разделы ===") - for entry in sorted(diff.new_apis, key=lambda e: e.title): - print(f" + {entry.slug:<30} {entry.title}") - print() - - if diff.removed_apis: - print("=== Удалённые API-разделы ===") - for local in sorted(diff.removed_apis, key=lambda f: f.path.name): - print(f" - {local.path.name}") - print() - - changed_sections = [s for s in diff.changed if s.has_changes] - if changed_sections: - print("=== Изменения в существующих разделах ===") - for section in changed_sections: - print(f"\n [{section.title}] ({section.filename})") - for op in section.added: - print(f" + {op}") - for op in section.removed: - print(f" - {op}") - print() - - sections_changed = len(changed_sections) - print( - f"Итог: {sections_changed} раздел(ов) изменено, " - f"{diff.total_added} операций добавлено, " - f"{diff.total_removed} удалено." - ) - if diff.new_apis: - print(f" Новых API-разделов: {len(diff.new_apis)}") - if diff.removed_apis: - print(f" Удалённых API-разделов: {len(diff.removed_apis)}") - - -def build_json_report(diff: DiffResult) -> dict[str, object]: - return { - "new_apis": [{"slug": e.slug, "title": e.title} for e in diff.new_apis], - "removed_apis": [f.path.name for f in diff.removed_apis], - "changed": [ - { - "slug": s.slug, - "title": s.title, - "filename": s.filename, - "added": [{"method": op.method, "path": op.path} for op in s.added], - "removed": [{"method": op.method, "path": op.path} for op in s.removed], - } - for s in diff.changed - if s.has_changes - ], - "summary": { - "new_api_count": len(diff.new_apis), - "removed_api_count": len(diff.removed_apis), - "changed_section_count": sum(1 for s in diff.changed if s.has_changes), - "total_added_ops": diff.total_added, - "total_removed_ops": diff.total_removed, - }, - } - - -def update_files(remote_entries: list[ApiEntry], local_files: dict[str, LocalFile]) -> None: - updated = 0 - created = 0 - for entry in remote_entries: - key = entry.norm_key - if key in local_files: - target = local_files[key].path - target.write_text( - json.dumps(entry.spec, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" - ) - updated += 1 - else: - info_title = str(entry.spec.get("info", {}).get("title", entry.title)) # type: ignore[union-attr] - filename = re.sub(r"[^A-Za-zА-Яа-яЁё0-9\[\]\-]", "", info_title) + ".json" - target = SPEC_DIR / filename - target.write_text( - json.dumps(entry.spec, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" - ) - created += 1 - print(f" Создан: {filename}", file=sys.stderr) - print(f"Обновлено: {updated}, создано: {created} файлов.", file=sys.stderr) - - -def main() -> None: - parser = argparse.ArgumentParser( - description="Скачать swagger-спецификации с developers.avito.ru и показать diff с docs/avito/api/." - ) - parser.add_argument( - "--update", - action="store_true", - help="Перезаписать файлы в docs/avito/api/ актуальными версиями.", - ) - parser.add_argument( - "--output", - type=Path, - metavar="PATH", - help="Сохранить машиночитаемый diff в JSON-файл.", - ) - args = parser.parse_args() - - print("Загружаю список API...", file=sys.stderr) - with httpx.Client(timeout=30) as client: - catalog = fetch_catalog(client) - remote_entries: list[ApiEntry] = [] - for item in catalog: - slug = item["slug"] - title = item.get("title", slug) - print(f" Скачиваю {slug} ({title})...", file=sys.stderr) - spec = fetch_spec(client, slug) - remote_entries.append(ApiEntry(slug=slug, title=title, spec=spec)) - - print(f"Скачано {len(remote_entries)} спецификаций.", file=sys.stderr) - - local_files = load_local_files() - diff = compute_diff(remote_entries, local_files) - - print_diff(diff) - - if args.output: - report = build_json_report(diff) - args.output.write_text( - json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" - ) - print(f"\nJSON-отчёт сохранён: {args.output}", file=sys.stderr) - - if args.update: - print("\nОбновляю файлы...", file=sys.stderr) - update_files(remote_entries, local_files) - - -if __name__ == "__main__": - 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 From 24b2c99dcbaa1b06f1ceb3cbbd8e30c77fd9f360 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Tue, 28 Apr 2026 23:17:42 +0300 Subject: [PATCH 04/15] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=BB=D0=B8=D1=88=D0=BD=D0=B5=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- todo.md | 580 -------------------------------------------------------- 1 file changed, 580 deletions(-) delete mode 100644 todo.md diff --git a/todo.md b/todo.md deleted file mode 100644 index a8064b0..0000000 --- a/todo.md +++ /dev/null @@ -1,580 +0,0 @@ -# Документация avito-py на MkDocs Material - -## 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`. - -Сейчас у пользователя есть `README.md` с quickstart и доменными how-to snippet'ами (как требует STYLEGUIDE § Documentation Structure), русские docstring'и в публичном API и `CHANGELOG.md` в корне. Docstring'и не считать готовыми к строгому reference-гейту: перед включением `pydocstyle`/`interrogate` нужен отдельный проход по публичным контрактам, потому что часть docstring'ов сейчас короткая и не покрывает Returns/Raises/идемпотентность по STYLEGUIDE. **Каркас сайта уже создан** (PR 1 в основном реализован) — см. раздел «Текущее состояние». - -STYLEGUIDE § Documentation Structure делает обязательными все четыре режима Diátaxis (tutorials / how-to / reference / explanations); usability_scorecard § 15 выделяет на документацию 7% итогового Score и фиксирует шесть подкритериев с измеримыми процедурами. - -Цель — **стабилизировать существующий каркас** и достроить сайт, **не подменяя README** (доменные how-to snippets в README остаются — это нормативное требование STYLEGUIDE.md:678), а дополняя его режимами, которые в README невозможно компактно уместить: полный reference, длинные how-to с диаграммами, explanations и deploy-версионирование. - -**Измеримые цели**: - -- новичок (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-страницы. - -Язык — только русский. Визуализация — сбалансированная (Mermaid, admonitions, tabbed code, без кастомной темы). Версионирование — через `mike`. - -## Текущее состояние (уже реализовано) - -PR 1 в основном сделан: - -- `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`. - -**Что сломано в текущем состоянии** (надо починить в PR 1): - -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. - -## Зафиксированные решения - -### Навигация - -- 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 - ``` - -### Код-блоки и аннотации - -`content.code.annotate` **остаётся включённым глобально** — это фича рендеринга, не синтаксис Python. Проблема не в ней, а в аннотационном синтаксисе `# (N)!` внутри Python-блоков: mktestdocs передаёт блок в Python как есть, и `# (1)!` — невалидный Python-комментарий в Material-смысле (хотя технически парсится, маркер-символ может путать инструменты). Поэтому: - -- **Правило**: в `tutorials/*.md` и `how-to/*.md` Python-блоки никогда не содержат аннотационных маркеров `# (N)!`. Это plain fenced code без Material-специфичного синтаксиса. -- `pymdownx.tabbed` тоже не используется в tutorials/how-to. -- В `explanations/` и `reference/` аннотации и вкладки разрешены — mktestdocs там не применяется. - -### Исполняемость примеров (mktestdocs harness) - -`mktestdocs` через `pytest tests/docs/`. Финальная политика: **все fenced code-блоки с меткой `python` или `pycon` в `README.md`, `tutorials/*.md` и `how-to/*.md` исполняются**. Bash, env, mermaid — не исполняются (нет метки `python`). - -Правила классификации примеров: - -- если блок показывает SDK-вызов и помечен как `python`/`pycon`, он обязан выполняться через docs-harness без сети; -- если блок иллюстративный и не должен исполняться, он не имеет метки `python`/`pycon` (`text`, `console`, `bash` и т.п.) и не считается copy-paste примером; -- в `reference/` и `explanations/` Python-блоки либо подключаются к тому же collector'у, либо заменяются на non-executable fence; скрытых непроверяемых SDK-примеров быть не должно; -- реальный HTTP допускается только в ручной TTFC-процедуре с настоящими ключами, а не в CI. - -**Проблема изоляции**: `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` устанавливаются в фикстуре как заглушки. - -Ограничение harness: monkeypatch только `AvitoClient.from_env()` покрывает tutorial-путь, но не покрывает Python-блоки, где конструируется `AvitoClient(client_id=..., client_secret=...)` или `AvitoClient(AvitoSettings(...))` и затем выполняется SDK-вызов. Зафиксированный контракт: - -- в 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 приватных полей запрещён. - -Не использовать хрупкий вариант «создать настоящий `AvitoClient`, потом заменить internals»: у `AvitoClient` нет публичного параметра `transport`, а STYLEGUIDE требует иммутабельности клиента после создания. Если в будущем понадобится полноценный `AvitoClient` с fake transport для docs-тестов, это отдельное публичное/тестовое API-решение, а не monkeypatch приватных полей. - -Это позволяет README/tutorials/how-to показывать реальный API (`from_env()`) для P1-аудитории, и при этом тестировать код без сетевого доступа. Скрипты, которые явно импортируют `FakeTransport` (how-to `testing-with-fake-transport.md`), работают напрямую без monkeypatch. - -**Дизайнерское правило**: каждый новый Python-блок в README/tutorials/how-to обязан работать с harness conftest без сетевых запросов. Если блок требует API-секрет или настоящий transport, это дефект документации, не test-skip. - -`scripts/check_docs_examples.py` публикует `reference-explanation-examples-report.json`; в PR 3 gate включается strict. Поле `reference_explanation_examples_gaps` добавляется в `docs-quality-report.json`. - -### Страница «Покрытие API» (coverage.md) - -`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`. - -**Важно**: `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 - -`docs/avito/api/*.json` остаётся **единственным authoritative source of truth** по API-контракту; `docs/avito/inventory.md` — это производный индекс для SDK/discovery/doc-generation, а не замена Swagger/OpenAPI-спекам. Поэтому финальный DoD не может опираться только на `inventory-coverage-report.json`. - -Зафиксированный контракт: - -- `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`. - -### 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`. - -Runtime deprecation warnings — это изменение поведения публичного SDK, а не документационная задача. Его нельзя считать частью автогенерации reference. Реализация runtime warnings, тест `tests/contracts/test_deprecation_warnings.py` и запись в `CHANGELOG.md` идут отдельным SDK-contract блоком в PR 2.5/PR 3 до финального DoD. - -PR 2.5 мержится до PR 2b. Причина: deprecated-admonition в reference не должен появляться раньше, чем runtime `DeprecationWarning` эмитируется по вызову символа. - -### Per-operation overrides — канонический набор - -Канонический набор overrides фиксируется в `reference/config.md` как таблица «тип операции → разрешённые overrides»: - -- read / list / probe: `timeout`, `retries`; -- write с `dry_run=False`: `timeout`, `retries`, `idempotency_key`; -- write с `dry_run=True`: `timeout`; -- pagination-чтение: `timeout`, `retries`, `page_size`. - -`check_public_docstrings.py` сверяет реальные override-параметры с этим списком; расхождение — docstring gap. - -### Exception metadata contract - -Каждый подкласс `AvitoError` обязан в `reference/exceptions.md` документировать поля: `operation`, `status`, `request_id`, `attempt`, `method`, `endpoint`. `check_public_docstrings.py` добавляет шестой обязательный аспект — «documented raised exceptions include metadata field list». - -### Security surface docs - -`explanations/security-and-redaction.md` покрывает: (1) что SDK гарантирует в логах и ошибках по STYLEGUIDE §Logging/§Errors, (2) контракт `debug_info()`, (3) как consumer-код не должен логировать результаты `to_dict()` без фильтрации. - -### Scorecard §12 disabled - -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`. - -### TTFC runbook - -TTFC-замер выполняется вручную ответственным мейнтейнером перед каждым релизом (и перед финальным DoD). Процедура фиксируется в `CONTRIBUTING.md` раздел «TTFC measurement». Результат пишется в поле `ttfc_minutes` текущего релизного `docs-quality-report.json`. - -### Расширение inventory (prerequisite финального DoD) - -`docs/avito/inventory.md` сейчас не содержит колонок `deprecated_since`, `replacement` и `removal_version`. Без них финальный DoD (`deprecated_without_replacement` пуст и deprecation-период ≥2 minor) **недостижим** — это не дефект плана сайта, это gap в source of truth. В scope PR 2 входит: - -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`. - -До заполнения этих полей финальный DoD не применяется; промежуточные PR мержатся с непустым отчётом. - -### Инструмент проверки ссылок (lychee) - -`lychee` — не Python-зависимость. Для `make docs-check` требует отдельной установки: - -- macOS: `brew install lychee` -- Linux/CI: `cargo binstall lychee` или GitHub Action [`lycheeverse/lychee-action`](https://github.com/lycheeverse/lychee-action) - -Установка документируется в `CONTRIBUTING.md`. Если lychee не найден — `make docs-check` падает с понятным сообщением (не silent skip). В CI lychee запускается через GitHub Action, не через Makefile. - -Конфигурация: `--exclude "avito\.ru"`, `--retry-wait-time 5`, `--max-retries 3`, `--timeout 30`. - -Для локальной работы без lychee доступна цель `make docs-strict` (только `mkdocs build --strict` + Python-gates). - -### Прочие решения - -- **Автогенерация 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/` - -``` -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/ -``` - -## Генерация 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 -``` - -## Разделение на этапы - -### 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. From 9eb71220ff26036239bcd22deaaf0615ed228c3d Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Tue, 28 Apr 2026 23:47:28 +0300 Subject: [PATCH 05/15] =?UTF-8?q?todo=20=D0=B4=D0=BB=D1=8F=20=D0=B4=D0=B5?= =?UTF-8?q?=D0=BA=D0=BE=D1=80=D0=B0=D1=82=D0=BE=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- todo.md | 411 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 todo.md diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..f65ab31 --- /dev/null +++ b/todo.md @@ -0,0 +1,411 @@ +# Swagger Binding Decorator + +## Цель + +В SDK должен быть единый машинно-проверяемый способ связать публичный SDK-метод с операцией из Swagger-спецификации. + +Swagger-файлы в `docs/avito/api/*.json` являются единственным источником истины по API-контракту: + +- HTTP method; +- path; +- path/query/header parameters; +- request body; +- content-type; +- response statuses; +- response schemas; +- error schemas; +- deprecated state. + +Декоратор и class-level metadata не должны дублировать API-контракт. Они описывают только: + +```text +какой SDK method соответствует какой Swagger operation +как contract-test runner должен вызвать этот SDK method +``` + +## 1. Публичный API Декоратора + +Модуль: + +```text +avito/core/swagger.py +``` + +Основной декоратор: + +```python +@swagger_operation( + method: str, + path: str, + *, + spec: str | None = None, + operation_id: str | None = None, + factory: str | None = None, + factory_args: Mapping[str, str] | None = None, + method_args: Mapping[str, str] | None = None, + deprecated: bool = False, + legacy: bool = False, +) +``` + +Пример: + +```python +class Chat: + __swagger_domain__ = "messenger" + __swagger_spec__ = "Мессенджер.json" + __sdk_factory__ = "chat" + + @swagger_operation( + "GET", + "/messenger/v1/accounts/{user_id}/chats/{chat_id}", + factory_args={ + "user_id": "path.user_id", + "chat_id": "path.chat_id", + }, + ) + def get(self) -> ChatInfo: + ... +``` + +## 2. Class-Level Metadata + +Публичные domain objects и section clients могут объявлять служебные поля: + +```python +__swagger_domain__: str +__swagger_spec__: str +__sdk_factory__: str +__sdk_factory_args__: Mapping[str, str] +``` + +Назначение: + +```text +__swagger_domain__ + Логический домен SDK: ads, messenger, orders, promotion, accounts и т.д. + Используется для группировки contract tests и отчетов линтера. + +__swagger_spec__ + Имя Swagger-файла из docs/avito/api/. + Используется как default spec для всех decorated методов класса. + +__sdk_factory__ + Имя factory method на AvitoClient. + Например: "chat" означает client.chat(...). + +__sdk_factory_args__ + Default mapping аргументов factory. + Используется, если method-level factory_args не указан. +``` + +Приоритет значений: + +```text +1. Значения из @swagger_operation(...) +2. Значения из class-level metadata +3. Auto-resolve через Swagger registry, если это безопасно и однозначно +``` + +## 3. Binding Model + +Декоратор должен записывать metadata в атрибут функции: + +```python +func.__swagger_binding__ +``` + +Тип: + +```python +@dataclass(frozen=True, slots=True) +class SwaggerOperationBinding: + method: str + path: str + spec: str | None + operation_id: str | None + factory: str | None + factory_args: Mapping[str, str] + method_args: Mapping[str, str] + deprecated: bool + legacy: bool +``` + +Требования: + +- `method` нормализуется в uppercase. +- `path` хранится в Swagger-формате: `/path/{param}`. +- `factory_args` и `method_args` внутри модели должны быть immutable mapping. +- Декоратор не должен менять поведение метода. +- Декоратор не должен выполнять загрузку Swagger-файлов на import time. + +## 4. Запрещенные Поля + +В декораторе запрещены любые поля, дублирующие Swagger-контракт: + +```python +response_model=... +request_model=... +request_schema=... +response_schema=... +success_statuses=... +error_statuses=... +content_type=... +required_fields=... +query_params=... +path_params=... +``` + +Причина: это создает второй источник истины и допускает расхождение со Swagger. + +## 5. Path Expressions + +`factory_args` и `method_args` описывают, как contract-test runner строит SDK-вызов из Swagger-generated request data. + +Разрешенные выражения: + +```text +path. path parameter +query. query parameter +header. header parameter +body весь request body +body. поле request body +constant. тестовая константа из controlled test registry +``` + +Примеры: + +```python +factory_args={ + "user_id": "path.user_id", + "item_id": "path.item_id", +} +``` + +```python +method_args={ + "request": "body", +} +``` + +```python +method_args={ + "limit": "query.limit", + "offset": "query.offset", +} +``` + +Ограничения: + +- `factory_args` и `method_args` не должны содержать Python expressions. +- Запрещены произвольные callables. +- Запрещены dotted paths, которые не относятся к Swagger request. +- `constant.*` разрешается только для заранее зарегистрированных тестовых констант. + +## 6. Swagger Operation Identity + +Операция определяется ключом: + +```text +spec + method + normalized_path +``` + +Если `spec` не указан, operation может быть найдена по: + +```text +method + normalized_path +``` + +только если совпадение среди всех Swagger-файлов ровно одно. + +`operation_id`, если указан, является дополнительной проверкой, а не основным источником истины. + +## 7. Линтер + +Нужен отдельный CLI-линтер: + +```bash +poetry run python scripts/lint_swagger_bindings.py +``` + +И make target: + +```bash +make swagger-lint +``` + +Линтер должен запускаться вместе с общей проверкой качества проекта. + +## 8. Что Проверяет Линтер + +### 8.1 Swagger Files + +Линтер загружает все файлы: + +```text +docs/avito/api/*.json +``` + +Проверяет: + +- JSON валиден; +- Swagger/OpenAPI структура поддерживается; +- все paths и operations извлекаются; +- каждая operation имеет стабильный ключ; +- нет дублей `spec + method + path`; +- path parameters в path совпадают с parameters/request definition. + +### 8.2 SDK Binding Discovery + +Линтер импортирует пакет `avito` и находит все функции/методы с: + +```python +__swagger_binding__ +``` + +Для каждого binding определяет: + +- module; +- class name; +- method name; +- effective `spec`; +- effective `factory`; +- effective `factory_args`; +- effective `method_args`; +- class-level metadata. + +### 8.3 Completeness + +Обязательные проверки: + +```text +1. Каждая Swagger operation имеет ровно один SDK binding. +2. Каждый SDK binding указывает на существующую Swagger operation. +3. Две SDK methods не могут ссылаться на одну Swagger operation. +4. Один SDK method не может иметь несколько bindings, кроме явно разрешенной политики. +5. spec из binding/class metadata должен существовать в docs/avito/api/. +6. method/path должны совпадать с operation из Swagger. +7. operation_id, если указан, должен совпадать со Swagger. +``` + +### 8.4 Deprecated / Legacy + +Проверки: + +```text +1. Если Swagger operation deprecated=true, binding должен иметь deprecated=True. +2. Если binding deprecated=True, Swagger operation тоже должна быть deprecated. +3. Если политика SDK требует legacy domain для deprecated operations, binding должен иметь legacy=True. +4. Non-deprecated operation не может иметь legacy=True без явного исключения. +``` + +Исключения, если они понадобятся, должны быть описаны в отдельном allowlist-файле с причиной и датой удаления. По умолчанию allowlist запрещен. + +### 8.5 Factory Validation + +Для каждого binding: + +```text +1. factory должен существовать на AvitoClient. +2. Если factory не указан в decorator, должен быть __sdk_factory__ на классе. +3. factory_args должны соответствовать сигнатуре factory. +4. method_args должны соответствовать сигнатуре decorated SDK method. +5. Required параметры factory/method должны быть покрыты mapping-ом. +6. В mapping не должно быть лишних аргументов. +``` + +### 8.6 Path Expression Validation + +Для каждого выражения в `factory_args` и `method_args`: + +```text +path. + должен существовать среди path parameters Swagger operation. + +query. + должен существовать среди query parameters Swagger operation. + +header. + должен существовать среди header parameters Swagger operation. + +body + Swagger operation должна иметь request body. + +body. + Swagger operation должна иметь request body schema, где поле существует. + +constant. + должен существовать в test constants registry. +``` + +### 8.7 Запрет Дублирования Контракта + +Линтер должен падать, если в `SwaggerOperationBinding` или decorator call появляются запрещенные поля: + +- statuses; +- schemas; +- content types; +- response models; +- request models; +- error models. + +Это можно проверять через сигнатуру декоратора и unit-тесты самого декоратора. + +## 9. Формат Ошибок Линтера + +Ошибка должна быть точной и actionable. + +Пример: + +```text +[SWAGGER_BINDING_NOT_FOUND] +Swagger operation has no SDK binding: +spec=Мессенджер.json +method=GET +path=/messenger/v1/accounts/{user_id}/chats/{chat_id} +``` + +```text +[SWAGGER_BINDING_DUPLICATE] +Multiple SDK methods bind the same Swagger operation: +spec=Объявления.json +method=GET +path=/core/v1/items/{item_id} +methods: +- avito.ads.domain.Ad.get +- avito.ads.client.AdsClient.get_item +``` + +```text +[SWAGGER_ARG_UNKNOWN_QUERY_PARAM] +Binding references unknown query parameter: +method=avito.messenger.domain.Chat.list +expression=query.page +swagger_operation=GET /messenger/v1/accounts/{user_id}/chats +known_query_params=[limit, offset] +``` + +## 10. Не Цель Декоратора + +Декоратор не должен: + +- генерировать SDK-код; +- валидировать реальные payload на runtime; +- выполнять HTTP; +- знать response statuses; +- знать schemas; +- заменять Swagger; +- заменять typed models. + +Runtime/request/response проверки делает `SwaggerFakeTransport` и contract tests, используя Swagger operation, найденную через binding. + +## Итоговый Инвариант + +```text +Swagger operation +↔ exactly one @swagger_operation SDK method +→ SwaggerFakeTransport validates actual HTTP request/response +→ contract tests validate all statuses and errors from Swagger +``` + +Декоратор дает строгую адресацию. Линтер гарантирует, что адресация полная и валидная. Swagger остается единственным источником API-контракта. From be615ff8095a87380e9dbd95875ca16d0c876401 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Wed, 29 Apr 2026 00:24:11 +0300 Subject: [PATCH 06/15] =?UTF-8?q?todo=20=D0=B4=D0=BB=D1=8F=20=D0=B4=D0=B5?= =?UTF-8?q?=D0=BA=D0=BE=D1=80=D0=B0=D1=82=D0=BE=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...\272\321\206\320\270\320\276\320\275.json" | 2 +- ...\220\320\262\320\270\321\202\320\276.json" | 2 +- .../api/CallTracking[\320\232\320\242].json" | 2 +- docs/avito/api/TrxPromo.json | 2 +- ...\260\320\261\320\276\321\202\320\260.json" | 21 +-- ...\200\321\203\320\267\320\272\320\260.json" | 6 +- ...\267\320\260\321\206\320\270\321\217.json" | 2 +- ...\202\320\265\320\263\320\270\321\217.json" | 2 +- ...\276\321\202\320\265\320\272\320\260.json" | 2 +- ...\274\320\276\321\201\321\202\320\270.json" | 2 +- ...\202\320\260\320\262\320\272\320\260.json" | 2 +- ...\203\320\275\321\202\320\276\320\262.json" | 2 +- ...\260\321\202\320\265\320\273\320\265.json" | 2 +- ...\200\320\265\320\275\320\264\320\260.json" | 2 +- ...\275\320\264\320\266\320\265\321\200.json" | 2 +- ...\201\321\202\320\262\320\270\321\217.json" | 2 +- ...\273\320\265\320\275\320\270\321\217.json" | 2 +- ...\266\320\265\320\275\320\270\320\265.json" | 2 +- ...\264\320\266\320\265\321\200\320\265.json" | 2 +- ...\202\320\267\321\213\320\262\321\213.json" | 2 +- ...\260\321\200\320\270\321\204\321\213.json" | 2 +- ...\260\320\267\320\260\320\274\320\270.json" | 2 +- ...\202\320\272\320\260\320\274\320\270.json" | 2 +- scripts/download_avito_api_specs.py | 143 ++++++++++++++++++ todo.md | 25 +++ 25 files changed, 203 insertions(+), 34 deletions(-) create mode 100644 scripts/download_avito_api_specs.py 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/scripts/download_avito_api_specs.py b/scripts/download_avito_api_specs.py new file mode 100644 index 0000000..d55111c --- /dev/null +++ b/scripts/download_avito_api_specs.py @@ -0,0 +1,143 @@ +"""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 download_specs(output_dir: Path, dry_run: bool) -> int: + catalog = fetch_catalog() + output_dir.mkdir(parents=True, exist_ok=True) + + 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="Показать целевые имена файлов без скачивания спецификаций.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + saved_count = download_specs(args.output_dir, args.dry_run) + 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/todo.md b/todo.md index f65ab31..8dc230c 100644 --- a/todo.md +++ b/todo.md @@ -23,6 +23,31 @@ Swagger-файлы в `docs/avito/api/*.json` являются единстве как 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. Обновить `docs/avito/inventory.md` по актуальным операциям, если скачанные спецификации расходятся с текущей инвентаризацией. +4. Реализовать `avito/core/swagger.py` с `SwaggerOperationBinding` и `@swagger_operation(...)`. +5. Расставить class-level metadata и decorators на публичных domain methods без дублирования Swagger-контракта. +6. Реализовать `scripts/lint_swagger_bindings.py` и `make swagger-lint`. +7. Добавить `make swagger-lint` в общий quality gate. +8. Добавить unit-тесты декоратора, линтера и contract tests через `SwaggerFakeTransport`. +9. Завершить проверкой: + +```bash +make check +make swagger-lint +``` + ## 1. Публичный API Декоратора Модуль: From 37e60645a1e97f1eb650ce97b3ad347ebde0b7f3 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Wed, 29 Apr 2026 00:49:03 +0300 Subject: [PATCH 07/15] =?UTF-8?q?=D0=9D=D0=B0=D1=87=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D1=8E=20=D0=B2=D1=8B=D0=BF=D0=BE=D0=BB=D0=BD=D1=8F=D1=82=D1=8C?= =?UTF-8?q?=20=D0=BF=D0=BB=D0=B0=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 11 +- Makefile | 2 - README.md | 4 +- STYLEGUIDE.md | 2 +- action_plan.md | 408 ++++++++++++++++++ docs/avito/inventory.md | 246 ----------- docs/site/assets/_gen_reference.py | 92 ++-- .../api-coverage-and-deprecations.md | 16 +- docs/site/explanations/index.md | 2 +- docs/site/reference/coverage.md | 4 +- docs/site/reference/index.md | 2 +- tests/contracts/test_deprecation_warnings.py | 98 ----- todo.md | 13 +- 13 files changed, 483 insertions(+), 417 deletions(-) create mode 100644 action_plan.md delete mode 100644 docs/avito/inventory.md delete mode 100644 tests/contracts/test_deprecation_warnings.py diff --git a/CLAUDE.md b/CLAUDE.md index 9776ec5..19d717f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,18 +41,11 @@ 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. -`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) - -**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. - -All 204 operations from the specs must be covered. A missing method is a defect. +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. ## STYLEGUIDE.md — strict compliance is mandatory diff --git a/Makefile b/Makefile index 3fea838..8dae8de 100644 --- a/Makefile +++ b/Makefile @@ -53,8 +53,6 @@ docs-strict: 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 diff --git a/README.md b/README.md index 6b0717a..a4b52c5 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![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) +[![API coverage](https://img.shields.io/badge/API%20coverage-204%2F204-success)](docs/site/reference/coverage.md) [![Docs](https://img.shields.io/badge/docs-latest-blue)](https://p141592.github.io/avito_python_api/) `avito-py` — синхронный Python SDK для работы с Avito API через единый объектный фасад `AvitoClient`. @@ -374,4 +374,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..b22d25b 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -704,7 +704,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. diff --git a/action_plan.md b/action_plan.md new file mode 100644 index 0000000..45fa5cf --- /dev/null +++ b/action_plan.md @@ -0,0 +1,408 @@ +# 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 build`; `swagger-lint` нужно добавить только после готовности strict completeness. + +Ограничения архитектуры: + +- Декоратор не должен дублировать 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-файл с причиной и датой удаления. + +## Этап 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. + +## Этап 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 нигде не упоминается как источник истины. + +## Критичный порядок + +Не начинать с `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 | 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..caab4e5 100644 --- a/docs/site/assets/_gen_reference.py +++ b/docs/site/assets/_gen_reference.py @@ -7,25 +7,22 @@ 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 EXCLUDED_PACKAGES = {"auth", "core", "testing"} +PACKAGE_ROOT = Path("avito") -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 +36,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 +81,22 @@ def write_domain_pages(rows: list[InventoryRow]) -> list[str]: return pages -def write_operations(rows: list[InventoryRow]) -> None: +def write_operations(packages: list[str]) -> None: 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" + "Страница перечисляет публичные доменные методы SDK. Подробные сигнатуры, " + "модели и docstring-контракты находятся на страницах доменных пакетов.\n\n" ) - file.write( - "| Описание | HTTP | SDK | Тип ответа | Deprecated |\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( - f"| {row.description} | {http}
`{row.document}` | " - f"{sdk} | `{row.response_type}` | {deprecated} |\n" - ) + file.write("| Пакет | Доменный объект | Метод |\n") + file.write("|---|---|---|\n") + for package in packages: + for domain_class in public_domain_classes(package): + for method_name in public_domain_methods(domain_class): + file.write( + f"| `{package}` | `{domain_class.__name__}` | " + f"`{domain_class.__name__}.{method_name}()` |\n" + ) def write_enums(packages: list[str]) -> None: @@ -126,10 +141,9 @@ 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) + packages = public_domain_packages() + domain_pages = write_domain_pages(packages) + write_operations(packages) write_enums(packages) write_summary(domain_pages) diff --git a/docs/site/explanations/api-coverage-and-deprecations.md b/docs/site/explanations/api-coverage-and-deprecations.md index 6c82b3f..2e982f6 100644 --- a/docs/site/explanations/api-coverage-and-deprecations.md +++ b/docs/site/explanations/api-coverage-and-deprecations.md @@ -1,28 +1,26 @@ # Покрытие 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. Справочник reference строится из публичной поверхности SDK и показывает доступные доменные объекты, методы, модели и deprecation metadata. ```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] --> sdk[avito/* public API] + sdk --> 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. Если символ устарел, пользователь должен получить предупреждение при вызове, а не только при чтении сайта. ## Гейты -`check_spec_inventory_sync.py` сверяет operation-level coverage: документ, раздел, HTTP method и path. `check_inventory_coverage.py` сверяет связь inventory с публичной SDK-поверхностью и sanity deprecation-полей. +Публичная поверхность проверяется contract-тестами и сборкой reference-документации. Deprecated-символы должны сохранять runtime warning, а не только пометку в документации. Страница для пользователя: [покрытие API](../reference/coverage.md). Карта операций: [operations reference](../reference/operations.md). diff --git a/docs/site/explanations/index.md b/docs/site/explanations/index.md index c4617f9..beea9be 100644 --- a/docs/site/explanations/index.md +++ b/docs/site/explanations/index.md @@ -11,7 +11,7 @@ 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 связаны между собой | | [Resolution конфигурации](config-resolution.md) | Как env, `.env` и defaults превращаются в `AvitoSettings` | | [Security и redaction](security-and-redaction.md) | Какие секреты SDK не раскрывает в диагностике и ошибках | diff --git a/docs/site/reference/coverage.md b/docs/site/reference/coverage.md index 17dc3fa..c806302 100644 --- a/docs/site/reference/coverage.md +++ b/docs/site/reference/coverage.md @@ -1,6 +1,6 @@ # Покрытие API -SDK покрывает 204 операции Avito API. Swagger/OpenAPI-спецификации в `docs/avito/api/` остаются источником истины, а inventory фиксирует соответствие операций публичным методам SDK. +SDK покрывает 204 операции Avito API. Swagger/OpenAPI-спецификации в `docs/avito/api/` остаются источником истины, а справочник методов фиксирует публичную поверхность SDK. !!! info "Источник данных" Эта страница не ссылается на файлы вне `docs_dir` относительными путями, чтобы `mkdocs build --strict` оставался зелёным. Ссылки ниже ведут на файлы спецификаций в GitHub. @@ -31,4 +31,4 @@ SDK покрывает 204 операции Avito API. Swagger/OpenAPI-спец | Управление заказами | [Управлениезаказами.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) | -Полная карта «операция API → публичный метод SDK» хранится в [inventory.md](https://github.com/p141592/avito_python_api/blob/main/docs/avito/inventory.md). +Публичные методы SDK перечислены на странице [Методы API](operations.md). diff --git a/docs/site/reference/index.md b/docs/site/reference/index.md index c732ca7..f960c33 100644 --- a/docs/site/reference/index.md +++ b/docs/site/reference/index.md @@ -8,7 +8,7 @@ | [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](operations.md) | Индекс публичных доменных методов SDK | | Домены | Публичные объекты и модели каждого доменного пакета | | [Enum](enums.md) | Все публичные перечисления доменных пакетов | | [Модели](models.md) | Сериализация, dataclass-контракт, публичные модели | 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/todo.md b/todo.md index 8dc230c..cef6d52 100644 --- a/todo.md +++ b/todo.md @@ -35,13 +35,12 @@ 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. Обновить `docs/avito/inventory.md` по актуальным операциям, если скачанные спецификации расходятся с текущей инвентаризацией. -4. Реализовать `avito/core/swagger.py` с `SwaggerOperationBinding` и `@swagger_operation(...)`. -5. Расставить class-level metadata и decorators на публичных domain methods без дублирования Swagger-контракта. -6. Реализовать `scripts/lint_swagger_bindings.py` и `make swagger-lint`. -7. Добавить `make swagger-lint` в общий quality gate. -8. Добавить unit-тесты декоратора, линтера и contract tests через `SwaggerFakeTransport`. -9. Завершить проверкой: +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 From ef3cfd005d7122bf94ed1cd25ca957453b0a5c2d Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Wed, 29 Apr 2026 14:53:08 +0300 Subject: [PATCH 08/15] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=20=D0=BF=D0=BB=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/pull_request_template.md | 4 +- .github/workflows/docs.yml | 24 +- .gitignore | 1 + CLAUDE.md | 42 +- CONTRIBUTING.md | 17 +- Makefile | 14 +- README.md | 1 + STYLEGUIDE.md | 43 ++ action_plan.md | 144 ++++- avito/accounts/domain.py | 59 ++ avito/ads/domain.py | 211 +++++++ avito/auth/provider.py | 41 +- avito/autoteka/domain.py | 192 ++++++ avito/core/__init__.py | 3 + avito/core/deprecation.py | 19 + avito/core/swagger.py | 103 ++++ avito/core/swagger_discovery.py | 250 ++++++++ avito/core/swagger_factory_map.py | 230 ++++++++ avito/core/swagger_linter.py | 556 ++++++++++++++++++ avito/core/swagger_registry.py | 386 ++++++++++++ avito/core/swagger_report.py | 181 ++++++ avito/cpa/domain.py | 118 ++++ avito/jobs/domain.py | 181 ++++++ avito/messenger/domain.py | 140 +++++ avito/orders/domain.py | 355 +++++++++++ avito/promotion/domain.py | 190 +++++- avito/ratings/domain.py | 36 ++ avito/realty/domain.py | 65 ++ avito/tariffs/domain.py | 11 + avito/testing/__init__.py | 8 + avito/testing/swagger_fake_transport.py | 323 ++++++++++ ...\273\320\265\320\275\320\270\321\217.json" | 4 +- docs/site/assets/_gen_reference.py | 99 +++- docs/site/explanations/.pages | 1 + .../api-coverage-and-deprecations.md | 16 +- docs/site/explanations/index.md | 1 + .../explanations/swagger-binding-subsystem.md | 203 +++++++ docs/site/explanations/testing-strategy.md | 2 +- docs/site/reference/coverage.md | 39 +- docs/site/reference/index.md | 4 +- scripts/lint_swagger_bindings.py | 97 +++ tests/contracts/test_public_surface.py | 3 +- tests/contracts/test_swagger_contracts.py | 202 +++++++ tests/core/test_swagger.py | 105 ++++ tests/core/test_swagger_discovery.py | 114 ++++ tests/core/test_swagger_factory_map.py | 70 +++ tests/core/test_swagger_linter.py | 385 ++++++++++++ tests/core/test_swagger_registry.py | 127 ++++ tests/core/test_swagger_report.py | 163 +++++ 49 files changed, 5475 insertions(+), 108 deletions(-) create mode 100644 avito/core/swagger.py create mode 100644 avito/core/swagger_discovery.py create mode 100644 avito/core/swagger_factory_map.py create mode 100644 avito/core/swagger_linter.py create mode 100644 avito/core/swagger_registry.py create mode 100644 avito/core/swagger_report.py create mode 100644 avito/testing/swagger_fake_transport.py create mode 100644 docs/site/explanations/swagger-binding-subsystem.md create mode 100644 scripts/lint_swagger_bindings.py create mode 100644 tests/contracts/test_swagger_contracts.py create mode 100644 tests/core/test_swagger.py create mode 100644 tests/core/test_swagger_discovery.py create mode 100644 tests/core/test_swagger_factory_map.py create mode 100644 tests/core/test_swagger_linter.py create mode 100644 tests/core/test_swagger_registry.py create mode 100644 tests/core/test_swagger_report.py 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/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/.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 19d717f..0fb233a 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 @@ -44,9 +45,45 @@ poetry run pytest tests/test_facade.py::test_name ## 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. 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. +## Swagger binding subsystem + +The persistent subsystem context is documented in `docs/site/explanations/swagger-binding-subsystem.md`. + +Core invariant: + +```text +each Swagger operation -> exactly one discovered binding +``` + +One public SDK method may have multiple bindings only for an explicit multi-operation policy case, using stacked `@swagger_operation(...)` decorators. + +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 `STYLEGUIDE.md` is a normative document. All code changes **must** comply with it. When there is a conflict between any consideration and the STYLEGUIDE, the STYLEGUIDE takes priority. @@ -57,6 +94,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 8dae8de..ef6717d 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-lint build build: clean poetry build @@ -30,6 +30,9 @@ fmt: lint: poetry run ruff check . +swagger-lint: + poetry run python scripts/lint_swagger_bindings.py --strict + minor: check poetry version minor @@ -47,18 +50,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_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 a4b52c5..5350f0f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ SDK является синхронным. Любая асинхронная поддержка, если она появится, будет жить в отдельном namespace `avito.aio` и никогда не будет смешана с sync-классами в одном модуле. Каталог [docs/avito/api](docs/avito/api) рассматривается как upstream API contract. Эти файлы не редактируются вручную при развитии SDK: публичные модели, мапперы и тесты должны подстраиваться под documented shape из `docs/avito/api/*`. +Карта покрытия SDK строится из Swagger operation bindings на публичных доменных методах, а не из markdown inventory. ## Установка diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index b22d25b..9f2846f 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 @@ -803,6 +806,15 @@ 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 unless it is an explicitly justified multi-operation SDK method; +- 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` can invoke representative read/write/deprecated bindings without real HTTP. + ## 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 +823,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 may have multiple bindings only for an explicit multi-operation policy case, using stacked decorators and visible `__swagger_bindings__` metadata. - 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 +902,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 index 45fa5cf..77d62c5 100644 --- a/action_plan.md +++ b/action_plan.md @@ -33,8 +33,8 @@ Swagger operation - Публичный фасад: `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 build`; `swagger-lint` нужно добавить только после готовности strict completeness. +- Старые inventory-ссылки в `CLAUDE.md`, README, docs и генераторах документации мигрированы на binding discovery. +- `Makefile` сейчас имеет `check: test typecheck lint swagger-lint build`; strict `swagger-lint` уже входит в общий gate. Ограничения архитектуры: @@ -291,6 +291,14 @@ JSON report должен быть стабильным API для docs/reference 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`. @@ -298,6 +306,8 @@ JSON report должен быть стабильным API для docs/reference 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-ами по одному домену: @@ -377,6 +387,121 @@ JSON report должен быть стабильным API для docs/reference 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`. @@ -406,3 +531,18 @@ JSON report должен быть стабильным API для docs/reference | 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`; один SDK method может иметь несколько Swagger bindings для мульти-режимных операций; OAuth token operations покрыты явным non-domain auth binding exception; coverage report подтверждает 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 | 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/domain.py b/avito/ads/domain.py index 888d19b..7bcd424 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.codes"}, + ) 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_code"}, + ) 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.codes"}, + ) 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/auth/provider.py b/avito/auth/provider.py index 2d1edbb..9fad23b 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() @@ -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,20 @@ 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": "query.grant_type"}, + ) + @swagger_operation( + "POST", + "/token", + spec="Авторизация.json", + operation_id="getAccessToken", + method_args={"request": "body"}, + ) def request_client_credentials_token( self, request: ClientCredentialsRequest, @@ -297,6 +310,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 +321,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 +340,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..afc7037 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.brand_id"}, + ) 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.vehicles"}, + ) 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.vehicles"}, + ) 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_id", "mileage": "body.mileage"}, + ) def get_valuation_by_specification( self, *, specification_id: int, mileage: int ) -> AutotekaValuationInfo: 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/swagger.py b/avito/core/swagger.py new file mode 100644 index 0000000..61e31ca --- /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]: + existing = getattr(func, "__swagger_bindings__", ()) + if not isinstance(existing, tuple): + existing = () + func.__swagger_binding__ = binding # type: ignore[attr-defined] + func.__swagger_bindings__ = (*existing, 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..2b87e99 --- /dev/null +++ b/avito/core/swagger_discovery.py @@ -0,0 +1,250 @@ +"""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, ...] + + @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 = tuple( + binding + for module in (*domain_modules, *non_domain_modules) + for binding in _discover_module_bindings(module, registry) + ) + return SwaggerBindingDiscovery(bindings=bindings) + + +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[DiscoveredSwaggerBinding, ...]: + bindings: list[DiscoveredSwaggerBinding] = [] + 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 + for raw_binding in _method_bindings(func): + bindings.append( + _build_effective_binding( + module=module, + cls=cls, + method_name=method_name, + binding=raw_binding, + registry=registry, + ) + ) + return tuple(bindings) + + +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_bindings(func: object) -> tuple[SwaggerOperationBinding, ...]: + raw_binding = getattr(func, "__swagger_binding__", None) + raw_bindings = getattr(func, "__swagger_bindings__", None) + if isinstance(raw_bindings, tuple) and all( + isinstance(binding, SwaggerOperationBinding) for binding in raw_bindings + ): + if isinstance(raw_binding, SwaggerOperationBinding) and raw_binding not in raw_bindings: + return (raw_binding,) + return raw_bindings + if isinstance(raw_binding, SwaggerOperationBinding): + return (raw_binding,) + return () + + +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..67f0361 --- /dev/null +++ b/avito/core/swagger_linter.py @@ -0,0 +1,556 @@ +"""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_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_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": + if operation.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." + ), + ), + ) + 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..d6fe28a --- /dev/null +++ b/avito/core/swagger_registry.py @@ -0,0 +1,386 @@ +"""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_]*)}") + +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, ...] + + +@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) + _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(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(raw_request_body: object) -> SwaggerRequestBody | None: + if raw_request_body is None: + return None + request_body = _require_mapping(raw_request_body, "requestBody") + content = _require_mapping(request_body.get("content"), "requestBody.content") + return SwaggerRequestBody( + required=request_body.get("required") is True, + content_types=tuple(sorted(str(content_type) for content_type in content)), + ) + + +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 _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]: + 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 = "#/components/parameters/" + if not ref.startswith(prefix): + raise SwaggerRegistryError(f"{source}: поддерживаются только локальные parameter refs.") + parameter_name = ref.removeprefix(prefix) + components = _require_mapping(spec.get("components"), "components") + parameters = _require_mapping(components.get("parameters"), "components.parameters") + return _require_mapping(parameters.get(parameter_name), ref) + + +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/cpa/domain.py b/avito/cpa/domain.py index 9b8dfdf..bdd985f 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.reason"}, + ) 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,20 @@ 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.created_at_from"}, + ) + @swagger_operation( + "POST", + "/cpa/v1/chatsByTime", + spec="CPAАвито.json", + operation_id="chatsByTime", + method_args={"created_at_from": "body.created_at_from"}, + ) def list( self, *, @@ -109,6 +150,13 @@ def list( return client.list_by_time_classic(created_at_from=created_at_from, limit=limit) return client.list_by_time(created_at_from=created_at_from, limit=limit) + @swagger_operation( + "POST", + "/cpa/v1/phonesInfoFromChats", + spec="CPAАвито.json", + operation_id="phonesInfoFromChats", + method_args={"action_ids": "body.action_ids"}, + ) def get_phones_info_from_chats( self, *, @@ -133,8 +181,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_to"}, + ) def list(self, *, date_time_from: str, date_time_to: str) -> CpaCallsResult: """Выполняет публичную операцию `CpaCall.list` и возвращает типизированную SDK-модель. @@ -148,6 +206,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.reason"}, + ) def create_complaint(self, *, call_id: int, reason: str) -> CpaActionResult: """Выполняет публичную операцию `CpaCall.create_complaint` и возвращает типизированную SDK-модель. @@ -163,9 +228,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 +261,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 +285,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 +320,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 +346,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 +375,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/jobs/domain.py b/avito/jobs/domain.py index 4ab6130..719eb6c 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,27 @@ 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"}, + ) + @swagger_operation( + "POST", + "/job/v1/vacancies", + spec="АвитоРабота.json", + operation_id="vacancyCreate", + method_args={"title": "body.title"}, + ) def create( self, *, @@ -64,6 +83,20 @@ def create( return client.create_classic(title=title, idempotency_key=idempotency_key) return client.create(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"}, + ) + @swagger_operation( + "PUT", + "/job/v1/vacancies/{vacancy_id}", + spec="АвитоРабота.json", + operation_id="vacancyUpdate", + method_args={"title": "body.title"}, + ) def update( self, *, @@ -95,6 +128,13 @@ def update( 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 +157,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 +186,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 +202,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 +223,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 +240,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 +257,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 +296,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, *, @@ -240,6 +330,18 @@ def apply( idempotency_key=idempotency_key, ) + @swagger_operation( + "GET", + "/job/v1/applications/get_ids", + spec="АвитоРабота.json", + operation_id="applicationsGetIds", + ) + @swagger_operation( + "POST", + "/job/v1/applications/get_by_ids", + spec="АвитоРабота.json", + operation_id="applicationsGetByIds", + ) def list( self, *, @@ -260,6 +362,12 @@ def list( raise ValidationError("Для операции требуется `query` или `ids`.") return client.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 +378,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 +410,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 +433,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 +451,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 +479,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 +500,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 +516,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 +538,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 +566,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 +589,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/messenger/domain.py b/avito/messenger/domain.py index b21a7b8..bdcd422 100644 --- a/avito/messenger/domain.py +++ b/avito/messenger/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.messenger.client import MediaClient, MessengerClient, SpecialOffersClient, WebhookClient from avito.messenger.models import ( ChatInfo, @@ -28,9 +29,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 +53,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 +69,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 +89,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.blacklisted_user_id"}, + ) def blacklist( self, *, @@ -100,10 +130,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 +161,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 +189,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 +219,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 +267,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 +288,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 +305,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,8 +337,18 @@ 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 + @swagger_operation( + "GET", + "/messenger/v1/accounts/{user_id}/getVoiceFiles", + spec="Мессенджер.json", + operation_id="getVoiceFiles", + ) def get_voice_files(self) -> VoiceFilesResult: """Получает голосовые сообщения. @@ -260,6 +357,13 @@ def get_voice_files(self) -> VoiceFilesResult: return MediaClient(self.transport).get_voice_files(user_id=self._require_user_id()) + @swagger_operation( + "POST", + "/messenger/v1/accounts/{user_id}/uploadImages", + spec="Мессенджер.json", + operation_id="uploadImages", + method_args={"files": "body.files"}, + ) def upload_images( self, *, @@ -289,9 +393,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 +415,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.message"}, + ) def create_multi( self, *, @@ -322,6 +444,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 +468,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 +484,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/orders/domain.py b/avito/orders/domain.py index 7aa5987..f5e3768 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.order_id", "codes": "body.codes"}, + ) 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.postal_office_id"}, + ) 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.order_id", "code": "body.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.order_id", "pickup_point_id": "body.pickup_point_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_id"}, + ) 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,27 @@ 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"}, + ) + @swagger_operation( + "POST", + "/order-management/1/orders/labels/extended", + spec="Управлениезаказами.json", + operation_id="generateLabelsExtended", + method_args={"order_ids": "body.order_ids"}, + ) def create( self, *, @@ -236,6 +319,12 @@ def create( 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 +346,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.order_id"}, + ) def create_announcement( self, *, order_id: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -276,6 +375,13 @@ def create_announcement( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/cancelAnnouncement", + spec="Доставка.json", + operation_id="CancelAnnouncement3PL", + method_args={"order_id": "body.order_id"}, + ) def delete(self, *, order_id: str, idempotency_key: str | None = None) -> DeliveryEntityResult: """Выполняет публичную операцию `DeliveryOrder.delete` и возвращает типизированную SDK-модель. @@ -291,6 +397,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 +426,13 @@ def create( idempotency_key=idempotency_key, ) + @swagger_operation( + "POST", + "/sandbox/changeParcels", + spec="Доставка.json", + operation_id="ChangeParcels", + method_args={"parcel_ids": "body.parcel_ids"}, + ) def update_change_parcels( self, *, parcel_ids: Sequence[str], idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -330,6 +450,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.parcel_id", "result": "body.result"}, + ) def create_change_parcel_result( self, *, parcel_id: str, result: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -353,8 +480,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.order_id"}, + ) def create_announcement( self, *, order_id: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -372,6 +509,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.order_id"}, + ) def track_announcement( self, *, order_id: str, idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -389,6 +533,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.items"}, + ) def update_custom_area_schedule( self, *, items: Sequence[CustomAreaScheduleEntry], idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -406,6 +557,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 +582,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 +607,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 +636,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 +661,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 +709,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 +733,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 +749,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.items"}, + ) def add_sorting_center( self, *, items: Sequence[SortingCenterUpload], idempotency_key: str | None = None ) -> DeliveryEntityResult: @@ -560,6 +773,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.areas"}, + ) def add_areas( self, *, @@ -582,6 +802,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.items"}, + ) def add_tags_to_sorting_center( self, *, @@ -604,6 +831,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.items"}, + ) def add_terminals( self, *, @@ -626,6 +860,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.items"}, + ) def update_terms( self, *, @@ -648,6 +889,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 +932,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.order_id", "parcel_id": "body.parcel_id"}, + ) def create_parcel( self, *, @@ -700,6 +961,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 +996,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 +1025,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 +1058,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 +1108,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 +1132,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 +1156,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 +1180,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 +1209,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 +1243,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 +1264,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/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/ratings/domain.py b/avito/ratings/domain.py index 8ce8ad6..c3e1193 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.text"}, + ) 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/realty/domain.py b/avito/realty/domain.py index 74ec801..aa355c3 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.min_stay_days"}, + ) 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.blocked_dates"}, + ) 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.periods"}, + ) 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/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..731907e --- /dev/null +++ b/avito/testing/swagger_fake_transport.py @@ -0,0 +1,323 @@ +"""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 typing import cast + +import httpx + +from avito.client import AvitoClient +from avito.core.swagger_discovery import DiscoveredSwaggerBinding +from avito.core.swagger_registry import SwaggerOperation, SwaggerRegistry +from avito.testing.fake_transport import FakeTransport, JsonValue, RecordedRequest + +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", + "dictionary_id": 104, + "item_id": 105, + "limit": 2, + "message_id": "message-1", + "order_id": 106, + "parcel_id": 107, + "report_id": 108, + "resume_id": 109, + "scoring_id": 110, + "tariff_id": 111, + "task_id": 112, + "user_id": 7, + "url": "https://example.test/file.xml", + "vacancy_id": 113, + "value": "value", + "vehicle_id": 114, +} +_BODY_VALUES: Mapping[str, SdkValue] = { + "action": "approve", + "action_id": 101, + "action_ids": [101], + "applies": [{"resume_id": 109}], + "auto_renewal": True, + "billing_type": "package", + "blacklisted_user_id": 7, + "blocked_dates": [{"date": "2026-05-01"}], + "brand_id": 1, + "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, + "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 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}") + 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_arguments( + self, + mapping: Mapping[str, str], + callable_object: Callable[..., object], + ) -> dict[str, object]: + arguments = { + argument_name: self._value_for_expression(expression) + for argument_name, expression in mapping.items() + } + signature = inspect.signature(callable_object) + for name, parameter in signature.parameters.items(): + if name == "self" or name in arguments: + continue + if parameter.default is inspect.Parameter.empty: + arguments[name] = self._value_for_name(name) + return arguments + + def _value_for_expression(self, expression: str) -> object: + if expression == "body": + return {"value": "value"} + 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._value_for_name(field_name) + raise AssertionError(f"Неподдерживаемое binding expression: {expression}") + + 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 in _BODY_VALUES: + return _BODY_VALUES[name] + if name in _SDK_CONSTANTS: + return _SDK_CONSTANTS[name] + return f"{name}-value" + + 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) + 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: + 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 _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}, + } + + +__all__ = ("SwaggerFakeTransport", "SwaggerRoute", "error_payload") 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 b7e5c1c..0fe26fa 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" @@ -1375,7 +1375,7 @@ }, "openapi": "3.0.0", "paths": { - "/core/v1/accounts/{userId}/vas/prices": { + "/core/v1/accounts/{user_id}/vas/prices": { "parameters": [ { "$ref": "#/components/parameters/pathUserId" @@ -2009,7 +2009,7 @@ ] } }, - "/core/v2/items/{itemId}/vas/": { + "/core/v2/items/{item_id}/vas/": { "parameters": [ { "$ref": "#/components/parameters/pathItemId" diff --git a/docs/site/assets/_gen_reference.py b/docs/site/assets/_gen_reference.py index caab4e5..165153c 100644 --- a/docs/site/assets/_gen_reference.py +++ b/docs/site/assets/_gen_reference.py @@ -4,13 +4,19 @@ import inspect from enum import Enum from pathlib import Path +from urllib.parse import quote import mkdocs_gen_files 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() -> list[str]: @@ -81,22 +87,81 @@ def write_domain_pages(packages: list[str]) -> list[str]: return pages -def write_operations(packages: list[str]) -> 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( - "Страница перечисляет публичные доменные методы SDK. Подробные сигнатуры, " - "модели и docstring-контракты находятся на страницах доменных пакетов.\n\n" + "Страница строится из Swagger operation bindings и связывает каждую " + "upstream-операцию с публичным SDK-методом. Подробные сигнатуры, модели " + "и docstring-контракты находятся на страницах доменных пакетов.\n\n" ) - file.write("| Пакет | Доменный объект | Метод |\n") - file.write("|---|---|---|\n") - for package in packages: - for domain_class in public_domain_classes(package): - for method_name in public_domain_methods(domain_class): - file.write( - f"| `{package}` | `{domain_class.__name__}` | " - f"`{domain_class.__name__}.{method_name}()` |\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( + f"SDK покрывает {summary['bound']} из {summary['operations_total']} " + f"операций Avito API. Deprecated operations: " + f"{summary['deprecated_operations']}.\n\n" + ) + 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"| `{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_enums(packages: list[str]) -> None: @@ -141,9 +206,17 @@ def ensure_debug_info_exists() -> None: def main() -> None: ensure_debug_info_exists() + 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_operations(packages) + write_coverage(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 2e982f6..160f7d8 100644 --- a/docs/site/explanations/api-coverage-and-deprecations.md +++ b/docs/site/explanations/api-coverage-and-deprecations.md @@ -1,11 +1,13 @@ # Покрытие API и deprecation -Swagger/OpenAPI-файлы в `docs/avito/api/` считаются upstream source of truth. Справочник reference строится из публичной поверхности SDK и показывает доступные доменные объекты, методы, модели и deprecation metadata. +Swagger/OpenAPI-файлы в `docs/avito/api/` считаются upstream source of truth. Справочник reference строится из публичной поверхности SDK, а страницы покрытия и карты операций генерируются из Swagger binding report, который собирается через binding discovery на публичных SDK-методах. ```mermaid flowchart LR - spec[docs/avito/api/*.json] --> sdk[avito/* public API] - sdk --> reference[Generated reference] + 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] ``` @@ -19,8 +21,16 @@ OpenAPI описывает upstream API. Reference описывает публи 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-исключения с причиной и датой удаления. + ## Гейты Публичная поверхность проверяется contract-тестами и сборкой reference-документации. Deprecated-символы должны сохранять runtime warning, а не только пометку в документации. Страница для пользователя: [покрытие 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 beea9be..8c8bacd 100644 --- a/docs/site/explanations/index.md +++ b/docs/site/explanations/index.md @@ -12,6 +12,7 @@ Explanations описывают причины архитектурных реш | [Dry-run и идемпотентность](dry-run-and-idempotency.md) | Как write-операции проверяются без сетевого вызова | | [Стратегия тестирования](testing-strategy.md) | Как `FakeTransport`, contract-тесты и docs-harness проверяют SDK | | [Покрытие 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..24f0163 --- /dev/null +++ b/docs/site/explanations/swagger-binding-subsystem.md @@ -0,0 +1,203 @@ +# 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. + +## Основные компоненты + +| Компонент | Файл | Ответственность | +|---|---|---| +| 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__` и, для явно разрешённых multi-operation SDK methods, в `func.__swagger_bindings__`. Он не меняет поведение метода и не читает Swagger-файлы на import time. + +## Operation identity + +Primary key операции: + +```text +spec + method + normalized_path +``` + +Нормализация: + +- `method` приводится к uppercase; +- trailing slash удаляется, кроме `/`; +- path хранится в Swagger format: `/path/{param}`; +- 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`/`body.*` и наличие `constant.*` в test constants registry. Если требуется строгая field-level проверка `body.` против JSON schema properties, registry должен дополнительно извлекать request body schema metadata. + +## 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-lint +``` + +Non-strict mode валидирует specs и уже найденные bindings. Strict mode дополнительно требует, чтобы каждая Swagger operation имела ровно один binding. `make swagger-lint` запускает strict mode и входит в `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 +``` + +One SDK method may have multiple bindings only when one public method intentionally covers multiple upstream operations or modes. Such cases must be explicit through stacked `@swagger_operation(...)` decorators and must remain visible to discovery through `__swagger_bindings__`. + +This exception is narrow. It must not be used to hide duplicate public methods or to bind unrelated operations to a generic method. + +## 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. + +## 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, when needed, `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..85b0e5e 100644 --- a/docs/site/explanations/testing-strategy.md +++ b/docs/site/explanations/testing-strategy.md @@ -10,7 +10,7 @@ SDK тестируется через публичные контракты: д | Contract | Публичная поверхность, исключения, 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 diff --git a/docs/site/reference/coverage.md b/docs/site/reference/coverage.md index c806302..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/` остаются источником истины, а справочник методов фиксирует публичную поверхность 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 +``` -Публичные методы SDK перечислены на странице [Методы API](operations.md). +Сгенерированная страница показывает количество операций, bound/deprecated +статусы по каждому spec и ссылку на публичную карту операций. diff --git a/docs/site/reference/index.md b/docs/site/reference/index.md index f960c33..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) | Индекс публичных доменных методов SDK | +| [Покрытие API](coverage.md) | 204/204 Swagger operations из binding report | +| [Методы API](operations.md) | Карта Swagger operation → публичный SDK-метод | | Домены | Публичные объекты и модели каждого доменного пакета | | [Enum](enums.md) | Все публичные перечисления доменных пакетов | | [Модели](models.md) | Сериализация, dataclass-контракт, публичные модели | 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/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..3774869 --- /dev/null +++ b/tests/contracts/test_swagger_contracts.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import pytest + +from avito.accounts.models import AccountProfile, EmployeeItem +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 SwaggerRegistry, load_swagger_registry +from avito.testing import SwaggerFakeTransport, error_payload + + +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_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": 25, "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"}, + ) + + 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_swagger.py b/tests/core/test_swagger.py new file mode 100644 index 0000000..1b3e125 --- /dev/null +++ b/tests/core/test_swagger.py @@ -0,0 +1,105 @@ +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 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_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..37e881b --- /dev/null +++ b/tests/core/test_swagger_discovery.py @@ -0,0 +1,114 @@ +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 _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..1b3d4db --- /dev/null +++ b/tests/core/test_swagger_linter.py @@ -0,0 +1,385 @@ +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 discover_swagger_bindings +from avito.core.swagger_linter import lint_swagger_bindings +from avito.core.swagger_registry import 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_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": "body.unknown"}, + ) + + 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={"limit": "body.limit"}, + 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_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 + ] diff --git a/tests/core/test_swagger_registry.py b/tests/core/test_swagger_registry.py new file mode 100644 index 0000000..09510f5 --- /dev/null +++ b/tests/core/test_swagger_registry.py @@ -0,0 +1,127 @@ +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 [(response.status_code, response.content_types) for response in operation.responses] == [ + ("200", ("application/json",)), + ] + + +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_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}") From f2f0937deb7f8aa37f0b3adf9277fb87a6f17d1f Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Wed, 29 Apr 2026 19:07:18 +0300 Subject: [PATCH 09/15] =?UTF-8?q?=D0=92=D0=B5=D1=81=D1=8C=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=BF=D1=80=D0=BE=D1=88=D0=B5=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 3 +- STYLEGUIDE.md | 7 +- action_plan.md | 6 +- avito/ads/client.py | 6 +- avito/ads/domain.py | 6 +- avito/auth/provider.py | 24 +- avito/autoteka/domain.py | 8 +- avito/core/mapping.py | 2 + avito/core/swagger.py | 8 +- avito/core/swagger_discovery.py | 39 +- avito/core/swagger_linter.py | 72 ++- avito/core/swagger_registry.py | 166 ++++++- avito/cpa/client.py | 35 +- avito/cpa/domain.py | 56 ++- avito/jobs/domain.py | 116 +++-- avito/messenger/client.py | 10 +- avito/messenger/domain.py | 18 +- avito/orders/client.py | 19 +- avito/orders/domain.py | 73 +-- avito/promotion/client.py | 8 + avito/ratings/client.py | 1 + avito/ratings/domain.py | 2 +- avito/ratings/models.py | 6 + avito/realty/domain.py | 6 +- avito/testing/swagger_fake_transport.py | 442 +++++++++++++++++- .../explanations/swagger-binding-subsystem.md | 19 +- docs/site/explanations/testing-strategy.md | 6 +- tests/contracts/test_swagger_contracts.py | 138 +++++- tests/core/test_swagger.py | 11 +- tests/core/test_swagger_discovery.py | 21 + tests/core/test_swagger_linter.py | 166 ++++++- tests/core/test_swagger_registry.py | 108 +++++ 32 files changed, 1424 insertions(+), 184 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0fb233a..7fd7c87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,9 +57,10 @@ Core invariant: ```text each Swagger operation -> exactly one discovered binding +each discovered SDK method -> exactly one Swagger operation ``` -One public SDK method may have multiple bindings only for an explicit multi-operation policy case, using stacked `@swagger_operation(...)` decorators. +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: diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index 9f2846f..d7162dc 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -808,12 +808,13 @@ Rules: **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 unless it is an explicitly justified multi-operation SDK method; +- 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` can invoke representative read/write/deprecated bindings without real HTTP. +- `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 @@ -827,7 +828,7 @@ Rules: - Swagger bindings must not duplicate the API contract. Decorators and binding metadata must not contain request/response schemas, status lists, content types, response models, request models, error models, required fields, path parameter definitions, or query parameter definitions. - Public domain classes that expose bound methods should declare class-level metadata (`__swagger_domain__`, `__swagger_spec__`, `__sdk_factory__`, and when needed `__sdk_factory_args__`) so discovery can resolve bindings without creating `AvitoClient`, reading required environment variables, or doing network work. - The canonical coverage map is generated from Swagger registry plus discovered `@swagger_operation` bindings. Markdown inventory files and hand-written coverage tables must not be used as source of truth. -- Each Swagger operation must resolve to exactly one discovered binding in strict mode. One public SDK method may have multiple bindings only for an explicit multi-operation policy case, using stacked decorators and visible `__swagger_bindings__` metadata. +- 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. diff --git a/action_plan.md b/action_plan.md index 77d62c5..f4beae1 100644 --- a/action_plan.md +++ b/action_plan.md @@ -540,9 +540,13 @@ make check | 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`; один SDK method может иметь несколько Swagger bindings для мульти-режимных операций; OAuth token operations покрыты явным non-domain auth binding exception; coverage report подтверждает 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: 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/ads/client.py b/avito/ads/client.py index d03eceb..5f6d491 100644 --- a/avito/ads/client.py +++ b/avito/ads/client.py @@ -534,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: @@ -546,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( @@ -586,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 7bcd424..cdf0d37 100644 --- a/avito/ads/domain.py +++ b/avito/ads/domain.py @@ -347,7 +347,7 @@ def get_vas_prices( "/core/v1/accounts/{user_id}/items/{item_id}/vas", spec="Объявления.json", operation_id="putItemVas", - method_args={"codes": "body.codes"}, + method_args={"codes": "body.vas_id"}, ) def apply_vas( self, @@ -387,7 +387,7 @@ def apply_vas( "/core/v2/accounts/{user_id}/items/{item_id}/vas_packages", spec="Объявления.json", operation_id="putItemVasPackageV2", - method_args={"package_code": "body.package_code"}, + method_args={"package_code": "body.package_id"}, ) def apply_vas_package( self, @@ -427,7 +427,7 @@ def apply_vas_package( "/core/v2/items/{item_id}/vas", spec="Объявления.json", operation_id="applyVas", - method_args={"codes": "body.codes"}, + method_args={"codes": "body.slugs"}, ) def apply_vas_direct( self, diff --git a/avito/auth/provider.py b/avito/auth/provider.py index 9fad23b..bb05cac 100644 --- a/avito/auth/provider.py +++ b/avito/auth/provider.py @@ -79,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 @@ -205,13 +205,6 @@ 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": "query.grant_type"}, - ) @swagger_operation( "POST", "/token", @@ -234,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`.""" diff --git a/avito/autoteka/domain.py b/avito/autoteka/domain.py index afc7037..04515e9 100644 --- a/avito/autoteka/domain.py +++ b/avito/autoteka/domain.py @@ -52,7 +52,7 @@ class AutotekaVehicle(DomainObject): "/autoteka/v1/catalogs/resolve", spec="Автотека.json", operation_id="catalogsResolve", - method_args={"brand_id": "body.brand_id"}, + method_args={"brand_id": "body.fields_value_ids"}, ) def resolve_catalog(self, *, brand_id: int) -> CatalogResolveResult: """Актуализирует параметры автокаталога. @@ -493,7 +493,7 @@ class AutotekaMonitoring(DomainObject): "/autoteka/v1/monitoring/bucket/add", spec="Автотека.json", operation_id="monitoringBucketAdd", - method_args={"vehicles": "body.vehicles"}, + method_args={"vehicles": "body.data"}, ) def create_monitoring_bucket_add( self, *, vehicles: list[str], idempotency_key: str | None = None @@ -533,7 +533,7 @@ def delete_bucket(self, *, idempotency_key: str | None = None) -> MonitoringBuck "/autoteka/v1/monitoring/bucket/remove", spec="Автотека.json", operation_id="monitoringBucketRemove", - method_args={"vehicles": "body.vehicles"}, + method_args={"vehicles": "body.data"}, ) def remove_bucket( self, *, vehicles: list[str], idempotency_key: str | None = None @@ -644,7 +644,7 @@ class AutotekaValuation(DomainObject): "/autoteka/v1/valuation/by-specification", spec="Автотека.json", operation_id="valuationBySpecification", - method_args={"specification_id": "body.specification_id", "mileage": "body.mileage"}, + method_args={"specification_id": "body.specification", "mileage": "body.mileage"}, ) def get_valuation_by_specification( self, *, specification_id: int, mileage: int 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/swagger.py b/avito/core/swagger.py index 61e31ca..2f36b0f 100644 --- a/avito/core/swagger.py +++ b/avito/core/swagger.py @@ -90,11 +90,11 @@ def swagger_operation( ) def decorate(func: Callable[P, R]) -> Callable[P, R]: - existing = getattr(func, "__swagger_bindings__", ()) - if not isinstance(existing, tuple): - existing = () + if hasattr(func, "__swagger_binding__") or hasattr(func, "__swagger_bindings__"): + raise ConfigurationError( + "Несколько Swagger binding-ов на одном SDK method запрещены." + ) func.__swagger_binding__ = binding # type: ignore[attr-defined] - func.__swagger_bindings__ = (*existing, binding) # type: ignore[attr-defined] return func return decorate diff --git a/avito/core/swagger_discovery.py b/avito/core/swagger_discovery.py index 2b87e99..5c22608 100644 --- a/avito/core/swagger_discovery.py +++ b/avito/core/swagger_discovery.py @@ -54,6 +54,7 @@ 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]: @@ -77,12 +78,16 @@ def discover_swagger_bindings( non_domain_modules = tuple( importlib.import_module(name) for name in _NON_DOMAIN_BINDING_MODULES ) - bindings = tuple( - binding - for module in (*domain_modules, *non_domain_modules) - for binding in _discover_module_bindings(module, registry) + 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))), ) - return SwaggerBindingDiscovery(bindings=bindings) def _iter_domain_modules(package: ModuleType, package_name: str) -> tuple[ModuleType, ...]: @@ -104,8 +109,9 @@ def _iter_domain_modules(package: ModuleType, package_name: str) -> tuple[Module def _discover_module_bindings( module: ModuleType, registry: SwaggerRegistry | None, -) -> tuple[DiscoveredSwaggerBinding, ...]: +) -> 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 @@ -116,7 +122,11 @@ def _discover_module_bindings( for method_name, func in inspect.getmembers(cls, inspect.isfunction): if method_name.startswith("_"): continue - for raw_binding in _method_bindings(func): + 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, @@ -126,7 +136,7 @@ def _discover_module_bindings( registry=registry, ) ) - return tuple(bindings) + return tuple(bindings), tuple(legacy_binding_methods) def _is_discoverable_binding_class(cls: type[object]) -> bool: @@ -135,18 +145,11 @@ def _is_discoverable_binding_class(cls: type[object]) -> bool: return _optional_string(getattr(cls, "__swagger_domain__", None)) is not None -def _method_bindings(func: object) -> tuple[SwaggerOperationBinding, ...]: +def _method_binding(func: object) -> SwaggerOperationBinding | None: raw_binding = getattr(func, "__swagger_binding__", None) - raw_bindings = getattr(func, "__swagger_bindings__", None) - if isinstance(raw_bindings, tuple) and all( - isinstance(binding, SwaggerOperationBinding) for binding in raw_bindings - ): - if isinstance(raw_binding, SwaggerOperationBinding) and raw_binding not in raw_bindings: - return (raw_binding,) - return raw_bindings if isinstance(raw_binding, SwaggerOperationBinding): - return (raw_binding,) - return () + return raw_binding + return None def _build_effective_binding( diff --git a/avito/core/swagger_linter.py b/avito/core/swagger_linter.py index 67f0361..cadf488 100644 --- a/avito/core/swagger_linter.py +++ b/avito/core/swagger_linter.py @@ -52,6 +52,8 @@ def lint_swagger_bindings( 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)) @@ -72,6 +74,49 @@ def lint_swagger_bindings( 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], @@ -404,7 +449,8 @@ def _validate_expression( location="header", ) if prefix == "body": - if operation.request_body is None: + request_body = operation.request_body + if request_body is None: return ( _expression_error( binding=binding, @@ -415,6 +461,30 @@ def _validate_expression( ), ), ) + 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: diff --git a/avito/core/swagger_registry.py b/avito/core/swagger_registry.py index d6fe28a..f63ed6a 100644 --- a/avito/core/swagger_registry.py +++ b/avito/core/swagger_registry.py @@ -13,6 +13,8 @@ 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] @@ -41,10 +43,12 @@ class SwaggerParameter: @dataclass(frozen=True, slots=True) class SwaggerRequestBody: - """Минимальная metadata request body для будущей validation binding expressions.""" + """Metadata request body для validation binding expressions.""" required: bool content_types: tuple[str, ...] + field_names: tuple[str, ...] + schema_extracted: bool @dataclass(frozen=True, slots=True) @@ -222,7 +226,10 @@ def _load_spec(path: Path, errors: list[SwaggerValidationError]) -> SwaggerSpec: operation_id=_optional_string(operation.get("operationId")), deprecated=operation.get("deprecated") is True, parameters=parameters, - request_body=_extract_request_body(operation.get("requestBody")), + request_body=_extract_request_body( + spec=spec, + raw_request_body=operation.get("requestBody"), + ), responses=_extract_responses(operation.get("responses")), ) ) @@ -251,14 +258,50 @@ def _extract_parameters( return tuple(extracted) -def _extract_request_body(raw_request_body: object) -> SwaggerRequestBody | None: +def _extract_request_body( + *, + spec: Mapping[str, object], + raw_request_body: object, +) -> SwaggerRequestBody | None: if raw_request_body is None: return None - request_body = _require_mapping(raw_request_body, "requestBody") + 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, ) @@ -333,18 +376,123 @@ def _validate_unique_operation_keys( 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 = "#/components/parameters/" + prefix = f"#/components/{component_name}/" if not ref.startswith(prefix): - raise SwaggerRegistryError(f"{source}: поддерживаются только локальные parameter refs.") - parameter_name = ref.removeprefix(prefix) + raise SwaggerRegistryError( + f"{source}: поддерживаются только локальные refs components/{component_name}." + ) + object_name = ref.removeprefix(prefix) components = _require_mapping(spec.get("components"), "components") - parameters = _require_mapping(components.get("parameters"), "components.parameters") - return _require_mapping(parameters.get(parameter_name), ref) + 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, ...]: 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 bdd985f..e7885a7 100644 --- a/avito/cpa/domain.py +++ b/avito/cpa/domain.py @@ -45,7 +45,7 @@ class CpaLead(DomainObject): "/cpa/v1/createComplaintByActionId", spec="CPAАвито.json", operation_id="createComplaintByActionId", - method_args={"action_id": "body.action_id", "reason": "body.reason"}, + method_args={"action_id": "body.action_id", "reason": "body.message"}, ) def create_complaint_by_action_id( self, @@ -116,14 +116,7 @@ def get(self, *, action_id: int | str | None = None) -> CpaChatInfo: "/cpa/v2/chatsByTime", spec="CPAАвито.json", operation_id="chatsByTime", - method_args={"created_at_from": "body.created_at_from"}, - ) - @swagger_operation( - "POST", - "/cpa/v1/chatsByTime", - spec="CPAАвито.json", - operation_id="chatsByTime", - method_args={"created_at_from": "body.created_at_from"}, + method_args={"created_at_from": "body.date_time_from"}, ) def list( self, @@ -141,21 +134,46 @@ 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.action_ids"}, + method_args={"action_ids": "body.date_time_from"}, ) def get_phones_info_from_chats( self, @@ -191,7 +209,7 @@ class CpaCall(DomainObject): "/cpa/v2/callsByTime", spec="CPAАвито.json", operation_id="getCallsByTimeV2", - method_args={"date_time_from": "body.date_time_from", "date_time_to": "body.date_time_to"}, + 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-модель. @@ -211,7 +229,7 @@ def list(self, *, date_time_from: str, date_time_to: str) -> CpaCallsResult: "/cpa/v1/createComplaint", spec="CPAАвито.json", operation_id="postCreateComplaint", - method_args={"call_id": "body.call_id", "reason": "body.reason"}, + method_args={"call_id": "body.call_id", "reason": "body.message"}, ) def create_complaint(self, *, call_id: int, reason: str) -> CpaActionResult: """Выполняет публичную операцию `CpaCall.create_complaint` и возвращает типизированную SDK-модель. diff --git a/avito/jobs/domain.py b/avito/jobs/domain.py index 719eb6c..cdafb76 100644 --- a/avito/jobs/domain.py +++ b/avito/jobs/domain.py @@ -55,13 +55,6 @@ class Vacancy(DomainObject): operation_id="vacancyCreateV2", method_args={"title": "body.title"}, ) - @swagger_operation( - "POST", - "/job/v1/vacancies", - spec="АвитоРабота.json", - operation_id="vacancyCreate", - method_args={"title": "body.title"}, - ) def create( self, *, @@ -80,21 +73,39 @@ 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/v2/vacancies/update/{vacancy_uuid}", + "/job/v1/vacancies", spec="АвитоРабота.json", - operation_id="vacancyUpdateV2", - method_args={"title": "body.title"}, + 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( - "PUT", - "/job/v1/vacancies/{vacancy_id}", + "POST", + "/job/v2/vacancies/update/{vacancy_uuid}", spec="АвитоРабота.json", - operation_id="vacancyUpdate", + operation_id="vacancyUpdateV2", method_args={"title": "body.title"}, ) def update( @@ -117,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, @@ -128,6 +139,33 @@ 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}", @@ -330,18 +368,6 @@ def apply( idempotency_key=idempotency_key, ) - @swagger_operation( - "GET", - "/job/v1/applications/get_ids", - spec="АвитоРабота.json", - operation_id="applicationsGetIds", - ) - @swagger_operation( - "POST", - "/job/v1/applications/get_by_ids", - spec="АвитоРабота.json", - operation_id="applicationsGetByIds", - ) def list( self, *, @@ -355,12 +381,42 @@ 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", 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 bdcd422..6aa27b1 100644 --- a/avito/messenger/domain.py +++ b/avito/messenger/domain.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass from avito.core import ValidationError @@ -94,7 +95,7 @@ def mark_read(self, *, idempotency_key: str | None = None) -> MessageActionResul "/messenger/v2/accounts/{user_id}/blacklist", spec="Мессенджер.json", operation_id="postBlacklistV2", - method_args={"blacklisted_user_id": "body.blacklisted_user_id"}, + method_args={"blacklisted_user_id": "body.users"}, ) def blacklist( self, @@ -349,20 +350,27 @@ class ChatMedia(DomainObject): spec="Мессенджер.json", operation_id="getVoiceFiles", ) - def get_voice_files(self) -> VoiceFilesResult: + 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.files"}, + method_args={"files": "body.uploadfile[]"}, ) def upload_images( self, @@ -420,7 +428,7 @@ def get_available(self, *, item_ids: list[int]) -> SpecialOfferAvailableResult: "/special-offers/v1/multiCreate", spec="Рассылкаскидокиспецпредложенийвмессенджере.json", operation_id="openApiMultiCreate", - method_args={"item_ids": "body.item_ids", "message": "body.message"}, + method_args={"item_ids": "body.item_ids", "message": "body.item_ids"}, ) def create_multi( self, 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 f5e3768..18f8b5b 100644 --- a/avito/orders/domain.py +++ b/avito/orders/domain.py @@ -80,7 +80,7 @@ def list(self) -> OrdersResult: "/order-management/1/markings", spec="Управлениезаказами.json", operation_id="markings", - method_args={"order_id": "body.order_id", "codes": "body.codes"}, + method_args={"order_id": "body.markings", "codes": "body.markings"}, ) def update_markings( self, *, order_id: str, codes: Sequence[str], idempotency_key: str | None = None @@ -105,7 +105,7 @@ def update_markings( "/order-management/1/order/acceptReturnOrder", spec="Управлениезаказами.json", operation_id="acceptReturnOrder", - method_args={"order_id": "body.order_id", "postal_office_id": "body.postal_office_id"}, + 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 @@ -155,7 +155,7 @@ def apply( "/order-management/1/order/checkConfirmationCode", spec="Управлениезаказами.json", operation_id="checkConfirmationCode", - method_args={"order_id": "body.order_id", "code": "body.code"}, + 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 @@ -180,7 +180,7 @@ def check_confirmation_code( "/order-management/1/order/cncSetDetails", spec="Управлениезаказами.json", operation_id="cncSetDetails", - method_args={"order_id": "body.order_id", "pickup_point_id": "body.pickup_point_id"}, + 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 @@ -221,7 +221,7 @@ def get_courier_delivery_range(self) -> CourierRangesResult: "/order-management/1/order/setCourierDeliveryRange", spec="Управлениезаказами.json", operation_id="setCourierDeliveryRange", - method_args={"order_id": "body.order_id", "interval_id": "body.interval_id"}, + 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 @@ -285,13 +285,6 @@ class OrderLabel(DomainObject): operation_id="generateLabels", method_args={"order_ids": "body.order_ids"}, ) - @swagger_operation( - "POST", - "/order-management/1/orders/labels/extended", - spec="Управлениезаказами.json", - operation_id="generateLabelsExtended", - method_args={"order_ids": "body.order_ids"}, - ) def create( self, *, @@ -310,15 +303,37 @@ 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", @@ -356,7 +371,7 @@ class DeliveryOrder(DomainObject): "/createAnnouncement", spec="Доставка.json", operation_id="CreateAnnouncement3PL", - method_args={"order_id": "body.order_id"}, + method_args={"order_id": "body.announcement_id"}, ) def create_announcement( self, *, order_id: str, idempotency_key: str | None = None @@ -380,7 +395,7 @@ def create_announcement( "/cancelAnnouncement", spec="Доставка.json", operation_id="CancelAnnouncement3PL", - method_args={"order_id": "body.order_id"}, + method_args={"order_id": "body.announcement_id"}, ) def delete(self, *, order_id: str, idempotency_key: str | None = None) -> DeliveryEntityResult: """Выполняет публичную операцию `DeliveryOrder.delete` и возвращает типизированную SDK-модель. @@ -431,7 +446,7 @@ def create( "/sandbox/changeParcels", spec="Доставка.json", operation_id="ChangeParcels", - method_args={"parcel_ids": "body.parcel_ids"}, + method_args={"parcel_ids": "body.applications"}, ) def update_change_parcels( self, *, parcel_ids: Sequence[str], idempotency_key: str | None = None @@ -455,7 +470,7 @@ def update_change_parcels( "/delivery/order/changeParcelResult", spec="Доставка.json", operation_id="ChangeParcelResult", - method_args={"parcel_id": "body.parcel_id", "result": "body.result"}, + 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 @@ -490,7 +505,7 @@ class SandboxDelivery(DomainObject): "/delivery-sandbox/announcements/create", spec="Доставка.json", operation_id="CreateAnnouncement", - method_args={"order_id": "body.order_id"}, + method_args={"order_id": "body.announcement_id"}, ) def create_announcement( self, *, order_id: str, idempotency_key: str | None = None @@ -514,7 +529,7 @@ def create_announcement( "/delivery-sandbox/announcements/track", spec="Доставка.json", operation_id="TrackAnnouncement", - method_args={"order_id": "body.order_id"}, + method_args={"order_id": "body.announcement_id"}, ) def track_announcement( self, *, order_id: str, idempotency_key: str | None = None @@ -538,7 +553,7 @@ def track_announcement( "/delivery-sandbox/areas/custom-schedule", spec="Доставка.json", operation_id="customAreaSchedule", - method_args={"items": "body.items"}, + method_args={"items": "body"}, ) def update_custom_area_schedule( self, *, items: Sequence[CustomAreaScheduleEntry], idempotency_key: str | None = None @@ -754,7 +769,7 @@ def list_sorting_center(self) -> DeliverySortingCentersResult: "/delivery-sandbox/tariffs/sorting-center", spec="Доставка.json", operation_id="AddSortingCenter", - method_args={"items": "body.items"}, + method_args={"items": "body"}, ) def add_sorting_center( self, *, items: Sequence[SortingCenterUpload], idempotency_key: str | None = None @@ -778,7 +793,7 @@ def add_sorting_center( "/delivery-sandbox/tariffs/{tariff_id}/areas", spec="Доставка.json", operation_id="AddAreasSandbox", - method_args={"tariff_id": "path.tariff_id", "areas": "body.areas"}, + method_args={"tariff_id": "path.tariff_id", "areas": "body"}, ) def add_areas( self, @@ -807,7 +822,7 @@ def add_areas( "/delivery-sandbox/tariffs/{tariff_id}/tagged-sorting-centers", spec="Доставка.json", operation_id="AddTagsToSortingCenter", - method_args={"tariff_id": "path.tariff_id", "items": "body.items"}, + method_args={"tariff_id": "path.tariff_id", "items": "body"}, ) def add_tags_to_sorting_center( self, @@ -836,7 +851,7 @@ def add_tags_to_sorting_center( "/delivery-sandbox/tariffs/{tariff_id}/terminals", spec="Доставка.json", operation_id="AddTerminalsSandbox", - method_args={"tariff_id": "path.tariff_id", "items": "body.items"}, + method_args={"tariff_id": "path.tariff_id", "items": "body"}, ) def add_terminals( self, @@ -865,7 +880,7 @@ def add_terminals( "/delivery-sandbox/tariffs/{tariff_id}/terms", spec="Доставка.json", operation_id="UpdateTerms", - method_args={"tariff_id": "path.tariff_id", "items": "body.items"}, + method_args={"tariff_id": "path.tariff_id", "items": "body"}, ) def update_terms( self, @@ -937,7 +952,7 @@ def add_tariff( "/delivery-sandbox/v2/createParcel", spec="Доставка.json", operation_id="CreateSandboxParcelV2", - method_args={"order_id": "body.order_id", "parcel_id": "body.parcel_id"}, + method_args={"order_id": "body.items", "parcel_id": "body.items"}, ) def create_parcel( self, 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/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 c3e1193..f0f8ccc 100644 --- a/avito/ratings/domain.py +++ b/avito/ratings/domain.py @@ -58,7 +58,7 @@ class ReviewAnswer(DomainObject): "/ratings/v1/answers", spec="Рейтингииотзывы.json", operation_id="createReviewAnswerV1", - method_args={"review_id": "body.review_id", "text": "body.text"}, + method_args={"review_id": "body.review_id", "text": "body.message"}, ) def create( self, diff --git a/avito/ratings/models.py b/avito/ratings/models.py index 419635f..a82f456 100644 --- a/avito/ratings/models.py +++ b/avito/ratings/models.py @@ -12,6 +12,7 @@ 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 diff --git a/avito/realty/domain.py b/avito/realty/domain.py index aa355c3..7d080fc 100644 --- a/avito/realty/domain.py +++ b/avito/realty/domain.py @@ -60,7 +60,7 @@ def get_intervals( "/realty/v1/items/{item_id}/base", spec="Краткосрочнаяаренда.json", operation_id="postBaseParams", - method_args={"min_stay_days": "body.min_stay_days"}, + method_args={"min_stay_days": "body.minimal_duration"}, ) def update_base_params( self, *, min_stay_days: int, item_id: int | str | None = None @@ -99,7 +99,7 @@ class RealtyBooking(DomainObject): "/core/v1/accounts/{user_id}/items/{item_id}/bookings", spec="Краткосрочнаяаренда.json", operation_id="putBookingsInfo", - method_args={"blocked_dates": "body.blocked_dates"}, + method_args={"blocked_dates": "body.bookings"}, ) def update_bookings_info( self, @@ -181,7 +181,7 @@ class RealtyPricing(DomainObject): "/realty/v1/accounts/{user_id}/items/{item_id}/prices", spec="Краткосрочнаяаренда.json", operation_id="postRealtyPrices", - method_args={"periods": "body.periods"}, + method_args={"periods": "body.prices"}, ) def update_realty_prices( self, diff --git a/avito/testing/swagger_fake_transport.py b/avito/testing/swagger_fake_transport.py index 731907e..d3d570d 100644 --- a/avito/testing/swagger_fake_transport.py +++ b/avito/testing/swagger_fake_transport.py @@ -7,15 +7,23 @@ import re from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import cast +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 +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_]*)}") @@ -25,33 +33,47 @@ "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": [{"resume_id": 109}], + "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", @@ -69,6 +91,7 @@ "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"}], @@ -135,6 +158,25 @@ def add_operation( ) 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.""" @@ -153,6 +195,10 @@ def invoke_binding( 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) @@ -189,46 +235,368 @@ def _build_target( 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]: - arguments = { - argument_name: self._value_for_expression(expression) - for argument_name, expression in mapping.items() - } 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: - arguments[name] = self._value_for_name(name) + 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_expression(self, expression: str) -> object: + 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 {"value": "value"} + 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._value_for_name(field_name) + 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: @@ -246,12 +614,13 @@ def _match_route(self, request: RecordedRequest) -> SwaggerRoute: 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: + 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": @@ -275,6 +644,13 @@ def _validate_request(self, operation: SwaggerOperation, request: RecordedReques 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) @@ -320,4 +696,42 @@ def error_payload(status_code: int) -> JsonValue: } -__all__ = ("SwaggerFakeTransport", "SwaggerRoute", "error_payload") +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/site/explanations/swagger-binding-subsystem.md b/docs/site/explanations/swagger-binding-subsystem.md index 24f0163..b76f95d 100644 --- a/docs/site/explanations/swagger-binding-subsystem.md +++ b/docs/site/explanations/swagger-binding-subsystem.md @@ -55,7 +55,7 @@ __sdk_factory_args__: Mapping[str, str] 2. Значения из class-level metadata. 3. Auto-resolve через registry, только если `method + normalized_path` совпадает ровно с одной Swagger operation во всём corpus. -Decorator записывает metadata в `func.__swagger_binding__` и, для явно разрешённых multi-operation SDK methods, в `func.__swagger_bindings__`. Он не меняет поведение метода и не читает Swagger-файлы на import time. +Decorator записывает metadata в `func.__swagger_binding__`. Он не меняет поведение метода и не читает Swagger-файлы на import time. Повторная разметка того же SDK method запрещена, а legacy metadata `__swagger_bindings__` считается ошибкой совместимости. ## Operation identity @@ -90,7 +90,7 @@ spec + method + normalized_path Expressions не являются Python-кодом. Произвольные callables, dotted paths вне whitelist и transport/request DTO запрещены. -Текущая реализация валидирует `path.*`, `query.*`, `header.*`, наличие `requestBody` для `body`/`body.*` и наличие `constant.*` в test constants registry. Если требуется строгая field-level проверка `body.` против JSON schema properties, registry должен дополнительно извлекать request body schema metadata. +Текущая реализация валидирует `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 @@ -152,11 +152,10 @@ The strict invariant is: ```text each Swagger operation -> exactly one discovered binding +each discovered SDK method -> exactly one Swagger operation ``` -One SDK method may have multiple bindings only when one public method intentionally covers multiple upstream operations or modes. Such cases must be explicit through stacked `@swagger_operation(...)` decorators and must remain visible to discovery through `__swagger_bindings__`. - -This exception is narrow. It must not be used to hide duplicate public methods or to bind unrelated operations to a generic method. +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 @@ -172,6 +171,14 @@ This exception is narrow. It must not be used to hide duplicate public methods o 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: @@ -183,7 +190,7 @@ When adding or changing a public API method that corresponds to Avito API: 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, when needed, `SwaggerFakeTransport` contract tests. +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: diff --git a/docs/site/explanations/testing-strategy.md b/docs/site/explanations/testing-strategy.md index 85b0e5e..99f761c 100644 --- a/docs/site/explanations/testing-strategy.md +++ b/docs/site/explanations/testing-strategy.md @@ -7,7 +7,7 @@ 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 | Swagger binding discovery, reference surface, docstring contract | @@ -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/tests/contracts/test_swagger_contracts.py b/tests/contracts/test_swagger_contracts.py index 3774869..f7a6ce2 100644 --- a/tests/contracts/test_swagger_contracts.py +++ b/tests/contracts/test_swagger_contracts.py @@ -1,8 +1,12 @@ 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, @@ -15,9 +19,71 @@ ) from avito.core.pagination import PaginatedList from avito.core.swagger_discovery import DiscoveredSwaggerBinding, discover_swagger_bindings -from avito.core.swagger_registry import SwaggerRegistry, load_swagger_registry +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) @@ -26,6 +92,73 @@ def _binding(registry: SwaggerRegistry, operation_key: str) -> DiscoveredSwagger 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") @@ -62,7 +195,7 @@ def test_swagger_fake_transport_invokes_generated_write_call_and_validates_json_ assert request.method == "POST" assert request.path == "/listItemsByEmployeeIdV1" assert request.headers["content-type"] == "application/json" - assert request.json_body == {"employeeId": 10, "limit": 25, "offset": 0} + assert request.json_body == {"employeeId": 10, "limit": 2, "offset": 0} assert isinstance(result, PaginatedList) assert isinstance(result[0], EmployeeItem) @@ -193,6 +326,7 @@ def test_swagger_fake_transport_invokes_deprecated_legacy_operation_with_runtime {"user_id": 7, "is_enabled": True, "upload_url": "https://example.test/upload"}, ) + _WARNED_SYMBOLS.clear() with pytest.warns(DeprecationWarning): result = fake.invoke_binding(binding) diff --git a/tests/core/test_swagger.py b/tests/core/test_swagger.py index 1b3e125..9bfb453 100644 --- a/tests/core/test_swagger.py +++ b/tests/core/test_swagger.py @@ -9,7 +9,7 @@ import pytest import avito.core.swagger -from avito.core import SwaggerOperationBinding, swagger_operation +from avito.core import ConfigurationError, SwaggerOperationBinding, swagger_operation @contextmanager @@ -100,6 +100,15 @@ def test_swagger_operation_rejects_forbidden_kwargs_by_signature() -> None: ) +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 index 37e881b..a85bd63 100644 --- a/tests/core/test_swagger_discovery.py +++ b/tests/core/test_swagger_discovery.py @@ -107,6 +107,27 @@ def test_discover_swagger_bindings_auto_resolves_spec_from_registry() -> None: 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: diff --git a/tests/core/test_swagger_linter.py b/tests/core/test_swagger_linter.py index 1b3d4db..bcbd084 100644 --- a/tests/core/test_swagger_linter.py +++ b/tests/core/test_swagger_linter.py @@ -6,9 +6,20 @@ 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 discover_swagger_bindings +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 load_swagger_registry +from avito.core.swagger_registry import ( + SwaggerOperation, + SwaggerRegistry, + SwaggerRequestBody, + SwaggerResponse, + SwaggerSpec, + load_swagger_registry, +) @contextmanager @@ -135,6 +146,53 @@ def test_lint_swagger_bindings_rejects_duplicate_operation_bindings() -> None: ] +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( @@ -184,7 +242,7 @@ def test_lint_swagger_bindings_validates_factory_and_method_signatures() -> None path="/linkItemsV1", spec="ИерархияАккаунтов.json", factory_args={"unknown": "constant.value"}, - method_args={"employee_id": "body.employee_id", "unknown": "body.unknown"}, + method_args={"employee_id": "body.employee_id", "unknown": "constant.value"}, ) with _temporary_binding( @@ -298,7 +356,7 @@ def test_lint_swagger_bindings_allows_valid_body_and_constant_expressions() -> N binding = SwaggerOperationBinding( method="POST", path="/core/v1/accounts/operations_history", - method_args={"limit": "body.limit"}, + method_args={"date_from": "body.date_time_from"}, factory_args={"user_id": "constant.user_id"}, ) @@ -314,6 +372,60 @@ def test_lint_swagger_bindings_allows_valid_body_and_constant_expressions() -> N ) == [] +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( @@ -383,3 +495,49 @@ def _codes_for( 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 index 09510f5..d636123 100644 --- a/tests/core/test_swagger_registry.py +++ b/tests/core/test_swagger_registry.py @@ -51,11 +51,119 @@ def test_load_swagger_registry_extracts_operation_contract_metadata() -> None: 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" From 5cf6eadd6929035b8d6e4d2c289d10a27ce8c1df Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Thu, 30 Apr 2026 18:28:54 +0300 Subject: [PATCH 10/15] =?UTF-8?q?=D0=9A=D0=B0=D0=B6=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=BF=D0=BE=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20=D0=B3=D0=BE=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 3 + Makefile | 10 +- README.md | 4 +- ...\273\320\265\320\275\320\270\321\217.json" | 4 +- docs/site/assets/_gen_reference.py | 114 ++++++++++++++++++ .../api-coverage-and-deprecations.md | 6 +- .../explanations/swagger-binding-subsystem.md | 5 +- docs/site/reference/.pages | 1 + scripts/download_avito_api_specs.py | 23 +++- 10 files changed, 162 insertions(+), 11 deletions(-) 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/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/Makefile b/Makefile index ef6717d..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 swagger-lint build +check: test typecheck lint swagger-coverage build build: clean poetry build @@ -30,9 +30,15 @@ fmt: lint: poetry run ruff check . -swagger-lint: +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 diff --git a/README.md b/README.md index 5350f0f..7834f3d 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@ [![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/site/reference/coverage.md) +[![API coverage](https://img.shields.io/badge/API%20coverage-204%2F204-brightgreen)](https://p141592.github.io/avito_python_api/reference/api-report/) +[![Swagger specs](https://img.shields.io/badge/Swagger%20specs-23-blue)](https://p141592.github.io/avito_python_api/reference/api-report/) +[![Swagger contract](https://img.shields.io/badge/Swagger%20contract-strict%20%2B%20complete-brightgreen)](https://p141592.github.io/avito_python_api/reference/api-report/) [![Docs](https://img.shields.io/badge/docs-latest-blue)](https://p141592.github.io/avito_python_api/) `avito-py` — синхронный Python SDK для работы с Avito API через единый объектный фасад `AvitoClient`. 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 0fe26fa..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" @@ -1375,7 +1375,7 @@ }, "openapi": "3.0.0", "paths": { - "/core/v1/accounts/{user_id}/vas/prices": { + "/core/v1/accounts/{userId}/vas/prices": { "parameters": [ { "$ref": "#/components/parameters/pathUserId" @@ -2009,7 +2009,7 @@ ] } }, - "/core/v2/items/{item_id}/vas/": { + "/core/v2/items/{itemId}/vas/": { "parameters": [ { "$ref": "#/components/parameters/pathItemId" diff --git a/docs/site/assets/_gen_reference.py b/docs/site/assets/_gen_reference.py index 165153c..81693f6 100644 --- a/docs/site/assets/_gen_reference.py +++ b/docs/site/assets/_gen_reference.py @@ -164,6 +164,118 @@ def write_coverage(report: dict[str, object]) -> None: 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: with mkdocs_gen_files.open("reference/enums.md", "w") as file: file.write("# Enum\n\n") @@ -182,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") @@ -216,6 +329,7 @@ def main() -> None: 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/api-coverage-and-deprecations.md b/docs/site/explanations/api-coverage-and-deprecations.md index 160f7d8..1ed9c8c 100644 --- a/docs/site/explanations/api-coverage-and-deprecations.md +++ b/docs/site/explanations/api-coverage-and-deprecations.md @@ -1,6 +1,6 @@ # Покрытие API и deprecation -Swagger/OpenAPI-файлы в `docs/avito/api/` считаются upstream source of truth. Справочник reference строится из публичной поверхности SDK, а страницы покрытия и карты операций генерируются из Swagger binding report, который собирается через binding discovery на публичных SDK-методах. +Swagger/OpenAPI-файлы в `docs/avito/api/` считаются upstream source of truth. Строгие проверки сначала обновляют этот каталог из публичного Avito developer portal, затем строят Swagger binding report через binding discovery на публичных SDK-методах. Справочник reference строится из публичной поверхности SDK, а страницы покрытия и карты операций генерируются из этого report. ```mermaid flowchart LR @@ -29,8 +29,8 @@ Operation-level `deprecated: true` в Swagger означает, что публ ## Гейты -Публичная поверхность проверяется contract-тестами и сборкой reference-документации. Deprecated-символы должны сохранять runtime warning, а не только пометку в документации. +Публичная поверхность проверяется contract-тестами и сборкой reference-документации. `make swagger-coverage` скачивает свежие Swagger files, запускает strict binding validation и полный contract suite. Deprecated-символы должны сохранять runtime warning, а не только пометку в документации. -Страница для пользователя: [покрытие API](../reference/coverage.md). Карта операций: [operations reference](../reference/operations.md). +Страница для пользователя: [покрытие API](../reference/coverage.md). Детальный отчёт: [отчёт покрытия API](../reference/api-report.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/swagger-binding-subsystem.md b/docs/site/explanations/swagger-binding-subsystem.md index b76f95d..a3b46dc 100644 --- a/docs/site/explanations/swagger-binding-subsystem.md +++ b/docs/site/explanations/swagger-binding-subsystem.md @@ -11,6 +11,7 @@ Swagger/OpenAPI-файлы в `docs/avito/api/*.json` остаются един | Компонент | Файл | Ответственность | |---|---|---| +| 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 | @@ -111,10 +112,12 @@ Discovery импортирует пакет `avito`, но не создаёт `A 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` запускает strict mode и входит в `make check`. +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: 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/scripts/download_avito_api_specs.py b/scripts/download_avito_api_specs.py index d55111c..cf791a3 100644 --- a/scripts/download_avito_api_specs.py +++ b/scripts/download_avito_api_specs.py @@ -87,9 +87,23 @@ def save_spec(spec: object, destination: Path) -> None: ) -def download_specs(output_dir: Path, dry_run: bool) -> int: +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: @@ -121,13 +135,18 @@ def parse_args() -> argparse.Namespace: 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) + saved_count = download_specs(args.output_dir, args.dry_run, args.clean) except RuntimeError as exc: print(exc, file=sys.stderr) return 1 From be444264537b75792b92c53cd469519fe75b3d29 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Thu, 30 Apr 2026 18:35:12 +0300 Subject: [PATCH 11/15] =?UTF-8?q?=D0=9A=D0=B0=D0=B6=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=BF=D0=BE=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20=D0=B3=D0=BE=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- avito/core/swagger_registry.py | 39 +++++++++++++++++++ .../explanations/swagger-binding-subsystem.md | 1 + tests/core/test_swagger_registry.py | 33 ++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/avito/core/swagger_registry.py b/avito/core/swagger_registry.py index f63ed6a..e94d884 100644 --- a/avito/core/swagger_registry.py +++ b/avito/core/swagger_registry.py @@ -211,6 +211,10 @@ def _load_spec(path: Path, errors: list[SwaggerValidationError]) -> SwaggerSpec: ) 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), @@ -353,6 +357,41 @@ def _validate_path_parameters( ) +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], diff --git a/docs/site/explanations/swagger-binding-subsystem.md b/docs/site/explanations/swagger-binding-subsystem.md index a3b46dc..7eb556f 100644 --- a/docs/site/explanations/swagger-binding-subsystem.md +++ b/docs/site/explanations/swagger-binding-subsystem.md @@ -71,6 +71,7 @@ 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}` запрещён. diff --git a/tests/core/test_swagger_registry.py b/tests/core/test_swagger_registry.py index d636123..9ff68f4 100644 --- a/tests/core/test_swagger_registry.py +++ b/tests/core/test_swagger_registry.py @@ -170,6 +170,39 @@ def test_normalize_swagger_path_removes_trailing_slash() -> None: ) +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( From 00e17e1fe954bea1359fa24a74e1a5d993eb982a Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Thu, 30 Apr 2026 18:39:43 +0300 Subject: [PATCH 12/15] =?UTF-8?q?=D0=91=D0=B5=D0=B9=D0=B4=D0=B6=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7834f3d..44264f0 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ [![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-brightgreen)](https://p141592.github.io/avito_python_api/reference/api-report/) -[![Swagger specs](https://img.shields.io/badge/Swagger%20specs-23-blue)](https://p141592.github.io/avito_python_api/reference/api-report/) -[![Swagger contract](https://img.shields.io/badge/Swagger%20contract-strict%20%2B%20complete-brightgreen)](https://p141592.github.io/avito_python_api/reference/api-report/) [![Docs](https://img.shields.io/badge/docs-latest-blue)](https://p141592.github.io/avito_python_api/) +[![Покрытие доменов](https://img.shields.io/badge/%D0%94%D0%BE%D0%BC%D0%B5%D0%BD%D1%8B-11%2F11-brightgreen)](https://p141592.github.io/avito_python_api/reference/api-report/) +[![Покрытие методов](https://img.shields.io/badge/%D0%9C%D0%B5%D1%82%D0%BE%D0%B4%D1%8B-204%2F204-brightgreen)](https://p141592.github.io/avito_python_api/reference/api-report/) +[![Покрытие структуры запроса и ответа](https://img.shields.io/badge/Request%2Fresponse-204%2F204-brightgreen)](https://p141592.github.io/avito_python_api/reference/api-report/) `avito-py` — синхронный Python SDK для работы с Avito API через единый объектный фасад `AvitoClient`. From a45a03d24e212c1e31f8f26501e7e550872e6b51 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Thu, 30 Apr 2026 18:42:16 +0300 Subject: [PATCH 13/15] readme --- README.md | 43 ++++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 44264f0..9b76212 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,32 @@ [![Покрытие методов](https://img.shields.io/badge/%D0%9C%D0%B5%D1%82%D0%BE%D0%B4%D1%8B-204%2F204-brightgreen)](https://p141592.github.io/avito_python_api/reference/api-report/) [![Покрытие структуры запроса и ответа](https://img.shields.io/badge/Request%2Fresponse-204%2F204-brightgreen)](https://p141592.github.io/avito_python_api/reference/api-report/) +| Покрытие API | Статус | +|---|---:| +| Домены SDK | 11 / 11 | +| Swagger operations | 204 / 204 | +| Request/response contract tests | 204 / 204 | +| Strict binding gate | проходит | + +Детальный отчёт: [покрытие API](https://p141592.github.io/avito_python_api/reference/api-report/). + +## Быстрый старт + +Получение ключей — 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_`. + `avito-py` — синхронный Python SDK для работы с Avito API через единый объектный фасад `AvitoClient`. Цели SDK: @@ -36,23 +62,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 предоставляет три нормативных способа создания клиента — от самого простого к самому явному. From 02d8c3b7ae1983e1ff8e0ccdc921a66f214eaa82 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Thu, 30 Apr 2026 18:43:39 +0300 Subject: [PATCH 14/15] readme --- README.md | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9b76212..2d2ae69 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,8 @@ [![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/) [![Docs](https://img.shields.io/badge/docs-latest-blue)](https://p141592.github.io/avito_python_api/) -[![Покрытие доменов](https://img.shields.io/badge/%D0%94%D0%BE%D0%BC%D0%B5%D0%BD%D1%8B-11%2F11-brightgreen)](https://p141592.github.io/avito_python_api/reference/api-report/) -[![Покрытие методов](https://img.shields.io/badge/%D0%9C%D0%B5%D1%82%D0%BE%D0%B4%D1%8B-204%2F204-brightgreen)](https://p141592.github.io/avito_python_api/reference/api-report/) -[![Покрытие структуры запроса и ответа](https://img.shields.io/badge/Request%2Fresponse-204%2F204-brightgreen)](https://p141592.github.io/avito_python_api/reference/api-report/) - -| Покрытие API | Статус | -|---|---:| -| Домены SDK | 11 / 11 | -| Swagger operations | 204 / 204 | -| Request/response contract tests | 204 / 204 | -| Strict binding gate | проходит | - -Детальный отчёт: [покрытие API](https://p141592.github.io/avito_python_api/reference/api-report/). + +Отчёт покрытия Avito API: [покрытие API](https://p141592.github.io/avito_python_api/reference/api-report/). ## Быстрый старт From 460a60cf37202839203463ba742e36b69b443dd1 Mon Sep 17 00:00:00 2001 From: Nikolay Baryshnikov Date: Thu, 30 Apr 2026 18:45:05 +0300 Subject: [PATCH 15/15] readme --- README.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/README.md b/README.md index 2d2ae69..f4893c7 100644 --- a/README.md +++ b/README.md @@ -26,18 +26,6 @@ print(ad.title) `avito-py` — синхронный Python SDK для работы с Avito API через единый объектный фасад `AvitoClient`. -Цели SDK: - -- скрыть transport, OAuth и retry-логику от пользовательского кода; -- возвращать типизированные `dataclass`-модели вместо сырого JSON; -- дать единый вход в доменные сценарии вида `avito.ad(...).get()` и `avito.chat(...).send_message(...)`; -- покрыть все swagger-документы из каталога [docs/avito/api](docs/avito/api). - -SDK является синхронным. Любая асинхронная поддержка, если она появится, будет жить в отдельном namespace `avito.aio` и никогда не будет смешана с sync-классами в одном модуле. - -Каталог [docs/avito/api](docs/avito/api) рассматривается как upstream API contract. Эти файлы не редактируются вручную при развитии SDK: публичные модели, мапперы и тесты должны подстраиваться под documented shape из `docs/avito/api/*`. -Карта покрытия SDK строится из Swagger operation bindings на публичных доменных методах, а не из markdown inventory. - ## Установка ```bash