yandex-audience-cli 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. yac/__init__.py +5 -0
  2. yac/__main__.py +6 -0
  3. yac/api/__init__.py +1 -0
  4. yac/api/accounts.py +15 -0
  5. yac/api/delegates.py +33 -0
  6. yac/api/grants.py +36 -0
  7. yac/api/pixels.py +29 -0
  8. yac/api/segments.py +135 -0
  9. yac/base.py +44 -0
  10. yac/cli.py +88 -0
  11. yac/client.py +204 -0
  12. yac/commands/__init__.py +1 -0
  13. yac/commands/accounts.py +20 -0
  14. yac/commands/delegates.py +52 -0
  15. yac/commands/grants.py +65 -0
  16. yac/commands/pixels.py +54 -0
  17. yac/commands/segments.py +247 -0
  18. yac/config.py +80 -0
  19. yac/context.py +68 -0
  20. yac/inputs.py +33 -0
  21. yac/models.py +164 -0
  22. yac/openapi_coverage.py +115 -0
  23. yac/output.py +53 -0
  24. yac/registry/__init__.py +60 -0
  25. yac/registry/_core.py +141 -0
  26. yac/registry/accounts/__init__.py +1 -0
  27. yac/registry/accounts/list.py +14 -0
  28. yac/registry/delegates/__init__.py +1 -0
  29. yac/registry/delegates/add.py +8 -0
  30. yac/registry/delegates/list.py +10 -0
  31. yac/registry/delegates/remove.py +14 -0
  32. yac/registry/grants/__init__.py +1 -0
  33. yac/registry/grants/add.py +15 -0
  34. yac/registry/grants/list.py +15 -0
  35. yac/registry/grants/remove.py +15 -0
  36. yac/registry/pixels/__init__.py +1 -0
  37. yac/registry/pixels/create.py +8 -0
  38. yac/registry/pixels/delete.py +15 -0
  39. yac/registry/pixels/list.py +8 -0
  40. yac/registry/pixels/undelete.py +15 -0
  41. yac/registry/pixels/update.py +10 -0
  42. yac/registry/segments/__init__.py +1 -0
  43. yac/registry/segments/confirm.py +15 -0
  44. yac/registry/segments/confirm_client_id.py +15 -0
  45. yac/registry/segments/create_appmetrica.py +14 -0
  46. yac/registry/segments/create_geo.py +14 -0
  47. yac/registry/segments/create_geo_polygon.py +14 -0
  48. yac/registry/segments/create_lookalike.py +14 -0
  49. yac/registry/segments/create_metrika.py +14 -0
  50. yac/registry/segments/create_pixel.py +14 -0
  51. yac/registry/segments/delete.py +15 -0
  52. yac/registry/segments/list.py +14 -0
  53. yac/registry/segments/modify_data.py +16 -0
  54. yac/registry/segments/reprocess.py +15 -0
  55. yac/registry/segments/update.py +15 -0
  56. yac/registry/segments/update_geo_points.py +15 -0
  57. yac/registry/segments/upload_csv_file.py +15 -0
  58. yac/registry/segments/upload_file.py +15 -0
  59. yac/render.py +38 -0
  60. yandex_audience_cli-0.0.1.dist-info/METADATA +120 -0
  61. yandex_audience_cli-0.0.1.dist-info/RECORD +63 -0
  62. yandex_audience_cli-0.0.1.dist-info/WHEEL +4 -0
  63. yandex_audience_cli-0.0.1.dist-info/entry_points.txt +2 -0
yac/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """yac — CLI для Yandex Audience API."""
2
+
3
+ from .cli import app, __version__
4
+
5
+ __all__ = ["app", "__version__"]
yac/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Запуск пакета как модуля: ``python -m yac ...``."""
2
+
3
+ from .cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
yac/api/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Слой ресурсов API — HTTP-классы по группам эндпоинтов."""
yac/api/accounts.py ADDED
@@ -0,0 +1,15 @@
1
+ """Ресурс «Аккаунты» — 1 эндпоинт."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..registry import GROUP_ACCOUNTS
8
+ from ..base import Resource
9
+
10
+
11
+ class Accounts(Resource):
12
+ group = GROUP_ACCOUNTS
13
+
14
+ def list(self, *, pretty: bool = False) -> Any:
15
+ return self._call("list", pretty=pretty)
yac/api/delegates.py ADDED
@@ -0,0 +1,33 @@
1
+ """Ресурс «Представители» (delegates) — 3 эндпоинта."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ from ..registry import GROUP_DELEGATES
8
+ from ..base import Resource
9
+
10
+
11
+ class Delegates(Resource):
12
+ group = GROUP_DELEGATES
13
+
14
+ def list(self, *, pretty: bool = False) -> Any:
15
+ return self._call("list", pretty=pretty)
16
+
17
+ def add(
18
+ self,
19
+ user_login: str,
20
+ *,
21
+ perm: Optional[str] = None,
22
+ comment: Optional[str] = None,
23
+ pretty: bool = False,
24
+ ) -> Any:
25
+ delegate: dict = {"user_login": user_login}
26
+ if perm is not None:
27
+ delegate["perm"] = perm
28
+ if comment is not None:
29
+ delegate["comment"] = comment
30
+ return self._call("add", json={"delegate": delegate}, pretty=pretty)
31
+
32
+ def remove(self, user_login: str, *, pretty: bool = False) -> Any:
33
+ return self._call("remove", params={"user_login": user_login}, pretty=pretty)
yac/api/grants.py ADDED
@@ -0,0 +1,36 @@
1
+ """Ресурс «Разрешения» (grants) — 3 эндпоинта."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ from ..registry import GROUP_GRANTS
8
+ from ..base import Resource
9
+
10
+
11
+ class Grants(Resource):
12
+ group = GROUP_GRANTS
13
+
14
+ def list(self, segment_id: int, *, pretty: bool = False) -> Any:
15
+ return self._call("list", id=segment_id, pretty=pretty)
16
+
17
+ def add(
18
+ self,
19
+ segment_id: int,
20
+ user_login: str,
21
+ *,
22
+ permission: Optional[str] = None,
23
+ comment: Optional[str] = None,
24
+ pretty: bool = False,
25
+ ) -> Any:
26
+ grant: dict = {"user_login": user_login}
27
+ if permission is not None:
28
+ grant["permission"] = permission
29
+ if comment is not None:
30
+ grant["comment"] = comment
31
+ return self._call("add", id=segment_id, json={"grant": grant}, pretty=pretty)
32
+
33
+ def remove(self, segment_id: int, user_login: str, *, pretty: bool = False) -> Any:
34
+ return self._call(
35
+ "remove", id=segment_id, params={"user_login": user_login}, pretty=pretty
36
+ )
yac/api/pixels.py ADDED
@@ -0,0 +1,29 @@
1
+ """Ресурс «Пиксели» — 5 эндпоинтов."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..registry import GROUP_PIXELS
8
+ from ..base import Resource
9
+
10
+
11
+ class Pixels(Resource):
12
+ group = GROUP_PIXELS
13
+
14
+ def list(self, *, pretty: bool = False) -> Any:
15
+ return self._call("list", pretty=pretty)
16
+
17
+ def create(self, name: str, *, pretty: bool = False) -> Any:
18
+ return self._call("create", json={"pixel": {"name": name}}, pretty=pretty)
19
+
20
+ def update(self, pixel_id: int, name: str, *, pretty: bool = False) -> Any:
21
+ return self._call(
22
+ "update", id=pixel_id, json={"pixel": {"name": name}}, pretty=pretty
23
+ )
24
+
25
+ def delete(self, pixel_id: int, *, pretty: bool = False) -> Any:
26
+ return self._call("delete", id=pixel_id, pretty=pretty)
27
+
28
+ def undelete(self, pixel_id: int, *, pretty: bool = False) -> Any:
29
+ return self._call("undelete", id=pixel_id, pretty=pretty)
yac/api/segments.py ADDED
@@ -0,0 +1,135 @@
1
+ """Ресурс «Сегменты» — 16 эндпоинтов.
2
+
3
+ Имена методов совпадают с ``op`` соответствующих эндпоинтов в registry.py.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+ from typing import Any, Optional
10
+
11
+ from ..registry import GROUP_SEGMENTS
12
+ from ..base import Resource
13
+
14
+
15
+ class Segments(Resource):
16
+ group = GROUP_SEGMENTS
17
+
18
+ # -- чтение -------------------------------------------------------------
19
+ def list(
20
+ self,
21
+ *,
22
+ pixel: Optional[int] = None,
23
+ limit: Optional[int] = None,
24
+ offset: Optional[int] = None,
25
+ pretty: bool = False,
26
+ ) -> Any:
27
+ params: dict = {}
28
+ if pixel is not None:
29
+ params["pixel"] = pixel
30
+ if limit is not None:
31
+ params["limit"] = limit
32
+ if offset is not None:
33
+ params["offset"] = offset
34
+ return self._call("list", params=params or None, pretty=pretty)
35
+
36
+ # -- создание сегментов по типам ---------------------------------------
37
+ def create_pixel(self, segment: dict, *, pretty: bool = False) -> Any:
38
+ return self._call("create_pixel", json={"segment": segment}, pretty=pretty)
39
+
40
+ def create_lookalike(self, segment: dict, *, pretty: bool = False) -> Any:
41
+ return self._call("create_lookalike", json={"segment": segment}, pretty=pretty)
42
+
43
+ def create_metrika(self, segment: dict, *, pretty: bool = False) -> Any:
44
+ return self._call("create_metrika", json={"segment": segment}, pretty=pretty)
45
+
46
+ def create_appmetrica(self, segment: dict, *, pretty: bool = False) -> Any:
47
+ return self._call("create_appmetrica", json={"segment": segment}, pretty=pretty)
48
+
49
+ def create_geo(self, segment: dict, *, pretty: bool = False) -> Any:
50
+ return self._call("create_geo", json={"segment": segment}, pretty=pretty)
51
+
52
+ def create_geo_polygon(self, segment: dict, *, pretty: bool = False) -> Any:
53
+ return self._call(
54
+ "create_geo_polygon", json={"segment": segment}, pretty=pretty
55
+ )
56
+
57
+ # -- загрузка файлов ----------------------------------------------------
58
+ def upload_file(self, file_path: str, *, pretty: bool = False) -> Any:
59
+ return self._upload("upload_file", file_path, pretty=pretty)
60
+
61
+ def upload_csv_file(self, file_path: str, *, pretty: bool = False) -> Any:
62
+ return self._upload("upload_csv_file", file_path, pretty=pretty)
63
+
64
+ def _upload(self, op: str, file_path: str, *, pretty: bool) -> Any:
65
+ path = Path(file_path)
66
+ with path.open("rb") as fh:
67
+ files = {"file": (path.name, fh)}
68
+ return self._call(op, files=files, pretty=pretty)
69
+
70
+ # -- изменение координат гео-окружности --------------------------------
71
+ def update_geo_points(
72
+ self, segment_id: int, points: list, *, pretty: bool = False
73
+ ) -> Any:
74
+ return self._call(
75
+ "update_geo_points", id=segment_id, json={"points": points}, pretty=pretty
76
+ )
77
+
78
+ # -- изменение данных загруженного сегмента (multipart) ----------------
79
+ def modify_data(
80
+ self,
81
+ segment_id: int,
82
+ file_path: str,
83
+ modification_type: str,
84
+ *,
85
+ check_size: Optional[bool] = None,
86
+ pretty: bool = False,
87
+ ) -> Any:
88
+ params: dict = {"modification_type": modification_type}
89
+ if check_size is not None:
90
+ params["check_size"] = str(check_size).lower()
91
+ path = Path(file_path)
92
+ with path.open("rb") as fh:
93
+ files = {"file": (path.name, fh)}
94
+ return self._call(
95
+ "modify_data", id=segment_id, params=params, files=files, pretty=pretty
96
+ )
97
+
98
+ # -- подтверждение/сохранение загруженного сегмента --------------------
99
+ def confirm(
100
+ self,
101
+ segment_id: int,
102
+ segment: dict,
103
+ *,
104
+ check_size: Optional[bool] = None,
105
+ pretty: bool = False,
106
+ ) -> Any:
107
+ params = (
108
+ {"check_size": str(check_size).lower()} if check_size is not None else None
109
+ )
110
+ return self._call(
111
+ "confirm",
112
+ id=segment_id,
113
+ params=params,
114
+ json={"segment": segment},
115
+ pretty=pretty,
116
+ )
117
+
118
+ def confirm_client_id(
119
+ self, segment_id: int, segment: dict, *, pretty: bool = False
120
+ ) -> Any:
121
+ return self._call(
122
+ "confirm_client_id", id=segment_id, json={"segment": segment}, pretty=pretty
123
+ )
124
+
125
+ # -- изменение / удаление / переобработка ------------------------------
126
+ def update(self, segment_id: int, segment: dict, *, pretty: bool = False) -> Any:
127
+ return self._call(
128
+ "update", id=segment_id, json={"segment": segment}, pretty=pretty
129
+ )
130
+
131
+ def delete(self, segment_id: int, *, pretty: bool = False) -> Any:
132
+ return self._call("delete", id=segment_id, pretty=pretty)
133
+
134
+ def reprocess(self, segment_id: int, *, pretty: bool = False) -> Any:
135
+ return self._call("reprocess", id=segment_id, pretty=pretty)
yac/base.py ADDED
@@ -0,0 +1,44 @@
1
+ """Базовый класс ресурса.
2
+
3
+ Связывает группу ресурса с реестром эндпоинтов и предоставляет хелпер
4
+ :meth:`Resource._call`, который берёт описание эндпоинта из registry.py
5
+ (HTTP-метод и путь) — так слой ресурсов не дублирует строки путей.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Mapping, Optional
11
+
12
+ from . import registry
13
+ from .client import Client
14
+
15
+
16
+ class Resource:
17
+ """Базовый ресурс. Подклассы задают ``group``."""
18
+
19
+ group: str = ""
20
+
21
+ def __init__(self, client: Client) -> None:
22
+ self._client = client
23
+
24
+ def _call(
25
+ self,
26
+ op: str,
27
+ *,
28
+ params: Optional[Mapping[str, Any]] = None,
29
+ json: Any = None,
30
+ files: Optional[Mapping[str, Any]] = None,
31
+ pretty: bool = False,
32
+ **path_params: object,
33
+ ) -> Any:
34
+ """Выполнить вызов эндпоинта ``self.group``/``op`` из реестра."""
35
+ ep = registry.get(self.group, op)
36
+ path = registry.render_path(ep, **path_params)
37
+ return self._client.request(
38
+ ep.method,
39
+ path,
40
+ params=params,
41
+ json=json,
42
+ files=files,
43
+ pretty=pretty,
44
+ )
yac/cli.py ADDED
@@ -0,0 +1,88 @@
1
+ """yac — CLI для Yandex Audience API.
2
+
3
+ Точка входа и сборка корневого Typer-приложения. Зеркалит структуру
4
+ HTTP API Яндекс.Аудиторий «1 в 1»:
5
+ версия (v1) → пространство (management) → 5 ресурсных групп
6
+ (segments, grants, pixels, accounts, delegates).
7
+
8
+ Единый источник правды по эндпоинтам — registry.py.
9
+ Запуск: ``yac ...`` (консольный скрипт) или ``python -m yac ...``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Optional
15
+
16
+ import typer
17
+
18
+ from .config import ENV_BASE_URL, ENV_TOKEN
19
+ from .context import AppState, get_state
20
+
21
+ from .commands import segments as segments_cli
22
+ from .commands import grants as grants_cli
23
+ from .commands import pixels as pixels_cli
24
+ from .commands import accounts as accounts_cli
25
+ from .commands import delegates as delegates_cli
26
+
27
+ __version__ = "0.0.1"
28
+
29
+ app = typer.Typer(
30
+ name="yac",
31
+ help="CLI для Yandex Audience API — структура API 1:1 (5 групп, 28 эндпоинтов).",
32
+ no_args_is_help=True,
33
+ add_completion=True,
34
+ )
35
+
36
+ # Под-приложения = ресурсные группы API.
37
+ app.add_typer(segments_cli.app, name="segments")
38
+ app.add_typer(grants_cli.app, name="grants")
39
+ app.add_typer(pixels_cli.app, name="pixels")
40
+ app.add_typer(accounts_cli.app, name="accounts")
41
+ app.add_typer(delegates_cli.app, name="delegates")
42
+
43
+
44
+ def _version_callback(value: bool) -> None:
45
+ if value:
46
+ typer.echo(f"yandex-audience-cli {__version__}")
47
+ raise typer.Exit()
48
+
49
+
50
+ @app.callback()
51
+ def main(
52
+ ctx: typer.Context,
53
+ token: Optional[str] = typer.Option(
54
+ None, "--token", envvar=ENV_TOKEN, help="OAuth-токен Яндекса."
55
+ ),
56
+ base_url: Optional[str] = typer.Option(
57
+ None,
58
+ "--base-url",
59
+ envvar=ENV_BASE_URL,
60
+ help="Базовый URL API (для тестов/прокси).",
61
+ ),
62
+ output: str = typer.Option(
63
+ "json", "--output", "-o", help="Формат вывода: json | table."
64
+ ),
65
+ pretty: bool = typer.Option(
66
+ False, "--pretty", help="Запрашивать pretty-ответ у API."
67
+ ),
68
+ _version: bool = typer.Option(
69
+ False,
70
+ "--version",
71
+ callback=_version_callback,
72
+ is_eager=True,
73
+ help="Версия CLI.",
74
+ ),
75
+ ) -> None:
76
+ """Общие опции применяются ко всем командам."""
77
+ state = get_state(ctx)
78
+ state.token = token
79
+ state.base_url = base_url
80
+ state.output = output
81
+ state.pretty = pretty
82
+
83
+
84
+ __all__ = ["app", "AppState", "__version__"]
85
+
86
+
87
+ if __name__ == "__main__":
88
+ app()
yac/client.py ADDED
@@ -0,0 +1,204 @@
1
+ """HTTP-клиент к Yandex Audience API поверх httpx.
2
+
3
+ Отвечает за единые правила транспорта:
4
+ - заголовок ``Authorization: OAuth <token>`` (именно OAuth, не Bearer);
5
+ - сборку URL относительно ``.../v1/management``;
6
+ - параметр ``pretty`` для форматированного ответа;
7
+ - загрузку файлов через ``multipart/form-data``;
8
+ - разбор ошибок API и отдельную обработку HTTP 429 (превышение квот).
9
+
10
+ Слой ресурсов (<group>_api.py) пользуется только методами этого класса
11
+ и не знает деталей транспорта.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ import time
18
+ from typing import Any, Mapping, Optional
19
+
20
+ import httpx
21
+
22
+ from .config import Config
23
+
24
+ DEFAULT_TIMEOUT = 30.0
25
+
26
+ #: Повторных попыток после HTTP 429 (итого попыток: RETRY_429_ATTEMPTS + 1).
27
+ RETRY_429_ATTEMPTS = 1
28
+ #: Пауза перед повтором, если API не отдал заголовок Retry-After.
29
+ RETRY_429_DEFAULT_WAIT = 60.0
30
+
31
+
32
+ def _sleep(seconds: float) -> None:
33
+ """Точка подмены в тестах (реальный сон — time.sleep)."""
34
+ time.sleep(seconds)
35
+
36
+
37
+ def _retry_wait(response: httpx.Response) -> float:
38
+ """Пауза до повтора: Retry-After от API, иначе дефолт. Мусорный заголовок не роняет."""
39
+ raw = response.headers.get("Retry-After")
40
+ if raw:
41
+ try:
42
+ return max(float(raw), 0.0)
43
+ except ValueError:
44
+ pass
45
+ return RETRY_429_DEFAULT_WAIT
46
+ return RETRY_429_DEFAULT_WAIT
47
+
48
+
49
+ class APIError(RuntimeError):
50
+ """Ошибка, вернувшаяся от API (или транспортная)."""
51
+
52
+ def __init__(
53
+ self,
54
+ message: str,
55
+ *,
56
+ status_code: Optional[int] = None,
57
+ payload: Any = None,
58
+ ) -> None:
59
+ super().__init__(message)
60
+ self.status_code = status_code
61
+ self.payload = payload
62
+
63
+
64
+ class RateLimitError(APIError):
65
+ """HTTP 429 — превышены квоты запросов."""
66
+
67
+
68
+ class Client:
69
+ """Тонкий клиент над httpx.Client с авторизацией Audience API."""
70
+
71
+ def __init__(
72
+ self,
73
+ config: Config,
74
+ *,
75
+ timeout: float = DEFAULT_TIMEOUT,
76
+ transport: Optional[httpx.BaseTransport] = None,
77
+ ) -> None:
78
+ self.config = config
79
+ self._http = httpx.Client(
80
+ base_url=config.management_url,
81
+ headers={"Authorization": f"OAuth {config.token}"},
82
+ timeout=timeout,
83
+ transport=transport,
84
+ )
85
+
86
+ # -- контекстный менеджер ------------------------------------------------
87
+ def __enter__(self) -> "Client":
88
+ return self
89
+
90
+ def __exit__(self, *exc: object) -> None:
91
+ self.close()
92
+
93
+ def close(self) -> None:
94
+ self._http.close()
95
+
96
+ # -- основной примитив ---------------------------------------------------
97
+ def request(
98
+ self,
99
+ method: str,
100
+ path: str,
101
+ *,
102
+ params: Optional[Mapping[str, Any]] = None,
103
+ json: Any = None,
104
+ files: Optional[Mapping[str, Any]] = None,
105
+ pretty: bool = False,
106
+ ) -> Any:
107
+ """Выполнить запрос и вернуть разобранный JSON (или None).
108
+
109
+ ``path`` задаётся относительно пространства management,
110
+ например ``"segments"`` или ``"segment/123/grants"``.
111
+ """
112
+ query = dict(params or {})
113
+ if pretty:
114
+ query["pretty"] = 1
115
+
116
+ # HTTP 429 — не доменный отказ, а квотный голод: повтор безопасен (запрос
117
+ # отклонён до обработки), поэтому ретраим с паузой Retry-After/60с —
118
+ # иначе любой скрипт >10 записей/мин падает на 11-й. Пауза печатается в
119
+ # stderr, чтобы ожидание не было молчаливым.
120
+ response = self._send(method, path, query, json, files)
121
+ for attempt in range(RETRY_429_ATTEMPTS):
122
+ if response.status_code != 429:
123
+ break
124
+ wait = _retry_wait(response)
125
+ print(
126
+ f"HTTP 429: пауза {wait:g}с и повтор (осталось попыток: "
127
+ f"{RETRY_429_ATTEMPTS - attempt})…",
128
+ file=sys.stderr,
129
+ )
130
+ _sleep(wait)
131
+ response = self._send(method, path, query, json, files)
132
+
133
+ return self._handle(response)
134
+
135
+ def _send(
136
+ self,
137
+ method: str,
138
+ path: str,
139
+ query: Mapping[str, Any],
140
+ json: Any,
141
+ files: Optional[Mapping[str, Any]],
142
+ ) -> httpx.Response:
143
+ try:
144
+ return self._http.request(
145
+ method.upper(),
146
+ path.lstrip("/"),
147
+ params=query or None,
148
+ json=json,
149
+ files=files,
150
+ )
151
+ except httpx.HTTPError as exc: # сетевые/таймаут-ошибки
152
+ raise APIError(f"Сетевая ошибка: {exc}") from exc
153
+
154
+ # -- разбор ответа -------------------------------------------------------
155
+ @staticmethod
156
+ def _handle(response: httpx.Response) -> Any:
157
+ if response.status_code == 429:
158
+ raise RateLimitError(
159
+ "Превышена квота запросов (HTTP 429). "
160
+ "Лимиты: 30 req/s по IP, 5000 req/day по логину, "
161
+ "запись 10/мин · 100/час · 500/день.",
162
+ status_code=429,
163
+ payload=_safe_json(response),
164
+ )
165
+
166
+ if response.is_success:
167
+ if not response.content:
168
+ return None
169
+ return _safe_json(response)
170
+
171
+ payload = _safe_json(response)
172
+ message = _extract_error_message(payload) or response.text or "Ошибка API"
173
+ raise APIError(
174
+ f"HTTP {response.status_code}: {message}",
175
+ status_code=response.status_code,
176
+ payload=payload,
177
+ )
178
+
179
+
180
+ def _safe_json(response: httpx.Response) -> Any:
181
+ try:
182
+ return response.json()
183
+ except ValueError:
184
+ return None
185
+
186
+
187
+ def _extract_error_message(payload: Any) -> Optional[str]:
188
+ """Достать человекочитаемое сообщение из тела ошибки Audience API."""
189
+ if not isinstance(payload, Mapping):
190
+ return None
191
+ # Возможные формы: {"errors": [{"text": ...}]} или {"message": ...}
192
+ errors = payload.get("errors")
193
+ if isinstance(errors, list) and errors:
194
+ texts = [
195
+ str(e.get("text") or e.get("message") or e)
196
+ for e in errors
197
+ if isinstance(e, Mapping)
198
+ ]
199
+ if texts:
200
+ return "; ".join(texts)
201
+ for key in ("message", "error", "text"):
202
+ if payload.get(key):
203
+ return str(payload[key])
204
+ return None
@@ -0,0 +1 @@
1
+ """Слой CLI — Typer-команды по ресурсным группам."""
@@ -0,0 +1,20 @@
1
+ """Команды группы ``accounts``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from ..api.accounts import Accounts
8
+ from ..render import render
9
+ from ..context import get_state, handle_errors
10
+
11
+ app = typer.Typer(
12
+ help="Аккаунты, где вы — представитель (1 эндпоинт).", no_args_is_help=True
13
+ )
14
+
15
+
16
+ @app.command("list")
17
+ @handle_errors
18
+ def list_(ctx: typer.Context) -> None:
19
+ """Список аккаунтов."""
20
+ render(ctx, Accounts(get_state(ctx).client()).list(pretty=get_state(ctx).pretty))
@@ -0,0 +1,52 @@
1
+ """Команды группы ``delegates`` (представители)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from ..api.delegates import Delegates
10
+ from ..render import render
11
+ from ..context import get_state, handle_errors
12
+
13
+ app = typer.Typer(
14
+ help="Управление представителями (3 эндпоинта).", no_args_is_help=True
15
+ )
16
+
17
+
18
+ def _delegates(ctx: typer.Context) -> Delegates:
19
+ return Delegates(get_state(ctx).client())
20
+
21
+
22
+ @app.command("list")
23
+ @handle_errors
24
+ def list_(ctx: typer.Context) -> None:
25
+ """Список представителей."""
26
+ render(ctx, _delegates(ctx).list(pretty=get_state(ctx).pretty))
27
+
28
+
29
+ @app.command("add")
30
+ @handle_errors
31
+ def add(
32
+ ctx: typer.Context,
33
+ user_login: str = typer.Option(..., "--user-login"),
34
+ perm: Optional[str] = typer.Option(None, "--perm", help="view | edit (опц.)."),
35
+ comment: Optional[str] = typer.Option(None, "--comment"),
36
+ ) -> None:
37
+ """Добавить представителя."""
38
+ render(
39
+ ctx,
40
+ _delegates(ctx).add(
41
+ user_login, perm=perm, comment=comment, pretty=get_state(ctx).pretty
42
+ ),
43
+ )
44
+
45
+
46
+ @app.command("remove")
47
+ @handle_errors
48
+ def remove(
49
+ ctx: typer.Context, user_login: str = typer.Option(..., "--user-login")
50
+ ) -> None:
51
+ """Удалить представителя."""
52
+ render(ctx, _delegates(ctx).remove(user_login, pretty=get_state(ctx).pretty))