Skip to content

Commit 462dd99

Browse files
committed
Добавил более метод общей информации
1 parent dd4a919 commit 462dd99

19 files changed

Lines changed: 360 additions & 51 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ with AvitoClient.from_env() as avito:
131131
stats = avito.ad_stats(item_id=42, user_id=123).get_item_stats()
132132
```
133133

134+
`user_id` можно передать явно, задать через `AVITO_USER_ID` или оставить пустым для read-only вызовов, где SDK может определить пользователя через `account().get_self()`. Если идентификатор не удалось определить, SDK поднимает `ValidationError` с подсказкой, как вызвать метод правильно.
135+
136+
Статистические методы принимают `date`, `datetime` и ISO-строки, а в Avito API отправляют дату в формате `YYYY-MM-DD`. Модель `Listing` нормализует основные поля объявления: `title`, `price`, `status`, `description`, `url`, `category`, `city`, `published_at`, `updated_at`, `is_moderated`, `is_visible`.
137+
134138
### Автозагрузка
135139

136140
```python
@@ -266,6 +270,8 @@ with AvitoClient.from_env() as avito:
266270
tariff = avito.tariff().get_tariff_info()
267271
```
268272

273+
`review().list()` по умолчанию запрашивает первую страницу отзывов (`page=1`). Для явной пагинации передайте `ReviewsQuery(page=...)`.
274+
269275
## Пагинация
270276

271277
Публичные list-операции, которые поддерживают lazy pagination, возвращают обычные SDK-результаты, а поле `items` в них типизировано как `PaginatedList[T]` и ведёт себя как list-like коллекция.
@@ -307,6 +313,8 @@ with AvitoClient.from_env() as avito:
307313

308314
`AuthenticationError` (401) и `AuthorizationError` (403) — семантически разные ошибки и **не** состоят в отношении наследования. Тексты сообщений написаны на русском языке. Секреты (access token, `client_secret`, `Authorization`) автоматически санитайзятся из сообщений и metadata.
309315

316+
Для диагностики доступны структурированные поля `operation`, `status` / `status_code`, `error_code`, `message`, `details`, `retry_after`, `request_id`, `metadata`, `payload` и `headers`. Например, у `RateLimitError` можно прочитать `retry_after`, а у ошибок валидации — `details`, если upstream вернул подробности по параметрам.
317+
310318
## Отладка интеграции
311319

312320
SDK не раскрывает сырой transport в основном API, но даёт безопасный debug snapshot без секретов:

avito/accounts/domain.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
EmployeesResult,
1818
OperationRecord,
1919
)
20-
from avito.core import PaginatedList, ValidationError
20+
from avito.core import PaginatedList
2121
from avito.core.domain import DomainObject
2222

2323

@@ -45,9 +45,7 @@ def get_balance(self, user_id: int | None = None) -> AccountBalance:
4545
Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint.
4646
"""
4747

48-
resolved_user_id = user_id or (int(self.user_id) if self.user_id is not None else None)
49-
if resolved_user_id is None:
50-
raise ValidationError("Для операции требуется `user_id`.")
48+
resolved_user_id = self._resolve_user_id(user_id or self.user_id)
5149
return AccountsClient(self.transport).get_balance(user_id=resolved_user_id)
5250

5351
def get_operations_history(

avito/ads/domain.py

Lines changed: 43 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from collections.abc import Sequence
66
from dataclasses import dataclass
7-
from datetime import datetime
7+
from datetime import date, datetime
88

99
from avito.ads.client import (
1010
AdsClient,
@@ -63,8 +63,26 @@ def _preview_result(
6363
)
6464

6565

66-
def _serialize_datetime(value: datetime | None) -> str | None:
67-
return value.isoformat() if value is not None else None
66+
StatsDate = date | datetime | str
67+
68+
69+
def _serialize_stats_date(value: StatsDate | None) -> str | None:
70+
if value is None:
71+
return None
72+
if isinstance(value, datetime):
73+
return value.date().isoformat()
74+
if isinstance(value, date):
75+
return value.isoformat()
76+
normalized = value.strip()
77+
if not normalized:
78+
raise ValidationError("Дата статистики не должна быть пустой строкой.")
79+
try:
80+
return datetime.fromisoformat(normalized.replace("Z", "+00:00")).date().isoformat()
81+
except ValueError:
82+
try:
83+
return date.fromisoformat(normalized).isoformat()
84+
except ValueError as exc:
85+
raise ValidationError("Дата статистики должна быть в ISO-формате.") from exc
6886

6987

7088
@dataclass(slots=True, frozen=True)
@@ -97,7 +115,7 @@ def list(
97115
Raises: AvitoError с полями operation, status, request_id, attempt, method и endpoint.
98116
"""
99117

100-
user_id = int(self.user_id) if self.user_id is not None else None
118+
user_id = self._resolve_user_id(self.user_id)
101119
return AdsClient(self.transport).list_items(
102120
user_id=user_id, status=status, limit=limit, offset=offset
103121
)
@@ -128,9 +146,9 @@ def _require_item_id(self) -> int:
128146
return int(self.item_id)
129147

130148
def _require_ids(self) -> tuple[int, int]:
131-
if self.item_id is None or self.user_id is None:
132-
raise ValidationError("Для операции требуются `item_id` и `user_id`.")
133-
return int(self.item_id), int(self.user_id)
149+
if self.item_id is None:
150+
raise ValidationError("Для операции требуется `item_id`.")
151+
return int(self.item_id), self._resolve_user_id(self.user_id)
134152

135153

136154
@dataclass(slots=True, frozen=True)
@@ -144,8 +162,8 @@ def get_calls_stats(
144162
self,
145163
*,
146164
item_ids: list[int] | None = None,
147-
date_from: datetime | None = None,
148-
date_to: datetime | None = None,
165+
date_from: StatsDate | None = None,
166+
date_to: StatsDate | None = None,
149167
) -> CallsStatsResult:
150168
"""Получает статистику звонков.
151169
@@ -157,16 +175,16 @@ def get_calls_stats(
157175
return StatsClient(self.transport).get_calls_stats(
158176
user_id=user_id,
159177
item_ids=resolved_item_ids,
160-
date_from=_serialize_datetime(date_from),
161-
date_to=_serialize_datetime(date_to),
178+
date_from=_serialize_stats_date(date_from),
179+
date_to=_serialize_stats_date(date_to),
162180
)
163181

164182
def get_item_stats(
165183
self,
166184
*,
167185
item_ids: list[int] | None = None,
168-
date_from: datetime | None = None,
169-
date_to: datetime | None = None,
186+
date_from: StatsDate | None = None,
187+
date_to: StatsDate | None = None,
170188
fields: list[str] | None = None,
171189
) -> ItemStatsResult:
172190
"""Получает статистику по списку объявлений.
@@ -179,17 +197,17 @@ def get_item_stats(
179197
return StatsClient(self.transport).get_item_stats(
180198
user_id=user_id,
181199
item_ids=resolved_item_ids,
182-
date_from=_serialize_datetime(date_from),
183-
date_to=_serialize_datetime(date_to),
200+
date_from=_serialize_stats_date(date_from),
201+
date_to=_serialize_stats_date(date_to),
184202
fields=fields or [],
185203
)
186204

187205
def get_item_analytics(
188206
self,
189207
*,
190208
item_ids: list[int] | None = None,
191-
date_from: datetime | None = None,
192-
date_to: datetime | None = None,
209+
date_from: StatsDate | None = None,
210+
date_to: StatsDate | None = None,
193211
fields: list[str] | None = None,
194212
) -> ItemAnalyticsResult:
195213
"""Получает аналитику по профилю.
@@ -202,17 +220,17 @@ def get_item_analytics(
202220
return StatsClient(self.transport).get_item_analytics(
203221
user_id=user_id,
204222
item_ids=resolved_item_ids,
205-
date_from=_serialize_datetime(date_from),
206-
date_to=_serialize_datetime(date_to),
223+
date_from=_serialize_stats_date(date_from),
224+
date_to=_serialize_stats_date(date_to),
207225
fields=fields or [],
208226
)
209227

210228
def get_account_spendings(
211229
self,
212230
*,
213231
item_ids: list[int] | None = None,
214-
date_from: datetime | None = None,
215-
date_to: datetime | None = None,
232+
date_from: StatsDate | None = None,
233+
date_to: StatsDate | None = None,
216234
fields: list[str] | None = None,
217235
) -> AccountSpendings:
218236
"""Получает статистику расходов профиля.
@@ -225,15 +243,13 @@ def get_account_spendings(
225243
return StatsClient(self.transport).get_account_spendings(
226244
user_id=user_id,
227245
item_ids=resolved_item_ids,
228-
date_from=_serialize_datetime(date_from),
229-
date_to=_serialize_datetime(date_to),
246+
date_from=_serialize_stats_date(date_from),
247+
date_to=_serialize_stats_date(date_to),
230248
fields=fields or [],
231249
)
232250

233251
def _require_user_id(self) -> int:
234-
if self.user_id is None:
235-
raise ValidationError("Для операции требуется `user_id`.")
236-
return int(self.user_id)
252+
return self._resolve_user_id(self.user_id)
237253

238254

239255
@dataclass(slots=True, frozen=True)
@@ -360,9 +376,7 @@ def _require_item_id(self) -> int:
360376
return int(self.item_id)
361377

362378
def _require_user_id(self) -> int:
363-
if self.user_id is None:
364-
raise ValidationError("Для операции требуется `user_id`.")
365-
return int(self.user_id)
379+
return self._resolve_user_id(self.user_id)
366380

367381
def _require_ids(self) -> tuple[int, int]:
368382
return self._require_item_id(), self._require_user_id()

avito/ads/mappers.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ def _list(payload: Payload, *keys: str) -> list[Payload]:
5858
return []
5959

6060

61+
def _mapping(payload: Payload, *keys: str) -> Payload | None:
62+
for key in keys:
63+
value = payload.get(key)
64+
if isinstance(value, Mapping):
65+
return cast(Payload, value)
66+
return None
67+
68+
6169
def _str(payload: Payload, *keys: str) -> str | None:
6270
for key in keys:
6371
value = payload.get(key)
@@ -66,6 +74,20 @@ def _str(payload: Payload, *keys: str) -> str | None:
6674
return None
6775

6876

77+
def _nested_str(payload: Payload, *keys: str) -> str | None:
78+
value = _str(payload, *keys)
79+
if value is not None:
80+
return value
81+
for key in keys:
82+
nested = _mapping(payload, key)
83+
if nested is None:
84+
continue
85+
nested_value = _str(nested, "value", "name", "title", "slug", "status")
86+
if nested_value is not None:
87+
return nested_value
88+
return None
89+
90+
6991
def _datetime(payload: Payload, *keys: str) -> datetime | None:
7092
for key in keys:
7193
value = payload.get(key)
@@ -95,6 +117,11 @@ def _float(payload: Payload, *keys: str) -> float | None:
95117
continue
96118
if isinstance(value, (int, float)):
97119
return float(value)
120+
if isinstance(value, Mapping):
121+
nested = cast(Payload, value)
122+
nested_value = _float(nested, "value", "amount", "current", "price")
123+
if nested_value is not None:
124+
return nested_value
98125
return None
99126

100127

@@ -106,6 +133,16 @@ def _bool(payload: Payload, *keys: str) -> bool | None:
106133
return None
107134

108135

136+
def _visibility(payload: Payload) -> bool | None:
137+
visible = _bool(payload, "is_visible", "isVisible", "visible")
138+
if visible is not None:
139+
return visible
140+
status = _nested_str(payload, "status")
141+
if status is None:
142+
return None
143+
return status in {"active", "published", "visible"}
144+
145+
109146
def map_ad_item(payload: object) -> Listing:
110147
"""Преобразует объявление в dataclass."""
111148

@@ -114,14 +151,20 @@ def map_ad_item(payload: object) -> Listing:
114151
item_id=_int(data, "id", "item_id", "itemId"),
115152
user_id=_int(data, "user_id", "userId"),
116153
title=_str(data, "title"),
117-
description=_str(data, "description"),
154+
description=_str(data, "description", "descriptionHtml"),
118155
status=map_enum_or_unknown(
119-
_str(data, "status"),
156+
_nested_str(data, "status"),
120157
ListingStatus,
121158
enum_name="ads.listing_status",
122159
),
123160
price=_float(data, "price"),
124161
url=_str(data, "url", "link"),
162+
category=_nested_str(data, "category", "categoryName"),
163+
city=_nested_str(data, "city", "location"),
164+
published_at=_datetime(data, "published_at", "publishedAt", "created_at", "createdAt"),
165+
updated_at=_datetime(data, "updated_at", "updatedAt"),
166+
is_moderated=_bool(data, "is_moderated", "isModerated", "moderated"),
167+
is_visible=_visibility(data),
125168
)
126169

127170

avito/ads/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ class Listing(SerializableModel):
2020
status: ListingStatus | None
2121
price: float | None
2222
url: str | None
23+
category: str | None = None
24+
city: str | None = None
25+
published_at: datetime | None = None
26+
updated_at: datetime | None = None
27+
is_moderated: bool | None = None
28+
is_visible: bool | None = None
2329

2430

2531
@dataclass(slots=True, frozen=True)

avito/core/domain.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from dataclasses import dataclass
66
from typing import TYPE_CHECKING
77

8+
from avito.core.exceptions import ValidationError
9+
810
if TYPE_CHECKING:
911
from avito.core.transport import Transport
1012

@@ -15,5 +17,25 @@ class DomainObject:
1517

1618
transport: Transport
1719

20+
def _resolve_user_id(self, user_id: int | str | None = None) -> int:
21+
"""Возвращает user_id из аргумента, настроек SDK или профиля текущего пользователя."""
22+
23+
if user_id is not None:
24+
return int(user_id)
25+
26+
configured_user_id = self.transport.debug_info().user_id
27+
if configured_user_id is not None:
28+
return configured_user_id
29+
30+
from avito.accounts.client import AccountsClient
31+
32+
profile = AccountsClient(self.transport).get_self()
33+
if profile.user_id is None:
34+
raise ValidationError(
35+
"Для операции требуется `user_id`: передайте его в фабрику клиента, "
36+
"в метод операции или задайте `AVITO_USER_ID`."
37+
)
38+
return profile.user_id
39+
1840

1941
__all__ = ("DomainObject",)

avito/core/exceptions.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,21 +48,32 @@ class AvitoError(Exception):
4848
status_code: int | None = None
4949
error_code: str | None = None
5050
operation: str | None = None
51+
details: object | None = None
52+
retry_after: float | None = None
53+
request_id: str | None = None
5154
metadata: Mapping[str, object] = field(default_factory=dict)
5255
payload: object | None = None
5356
headers: Mapping[str, str] | None = None
5457

5558
def __post_init__(self) -> None:
5659
sanitized_payload = sanitize_metadata(self.payload)
60+
sanitized_details = sanitize_metadata(self.details)
5761
sanitized_headers = (
5862
sanitize_metadata(dict(self.headers)) if self.headers is not None else None
5963
)
6064
sanitized_metadata = sanitize_metadata(dict(self.metadata))
6165
Exception.__init__(self, self.message)
6266
object.__setattr__(self, "payload", sanitized_payload)
67+
object.__setattr__(self, "details", sanitized_details)
6368
object.__setattr__(self, "headers", sanitized_headers)
6469
object.__setattr__(self, "metadata", sanitized_metadata)
6570

71+
@property
72+
def status(self) -> int | None:
73+
"""HTTP-статус ответа API."""
74+
75+
return self.status_code
76+
6677
def __str__(self) -> str:
6778
details: list[str] = [self.message]
6879
if self.operation is not None:
@@ -71,6 +82,10 @@ def __str__(self) -> str:
7182
details.append(f"status={self.status_code}")
7283
if self.error_code is not None:
7384
details.append(f"code={self.error_code}")
85+
if self.retry_after is not None:
86+
details.append(f"retry_after={self.retry_after}")
87+
if self.request_id is not None:
88+
details.append(f"request_id={self.request_id}")
7489
return " ".join(details)
7590

7691

0 commit comments

Comments
 (0)