savetest-cli 0.1.0__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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
savetest_cli/cli.py ADDED
@@ -0,0 +1,401 @@
1
+ import json
2
+ import math
3
+ import sys
4
+ import time
5
+ import zipfile
6
+ from pathlib import Path
7
+ from typing import Any, Dict
8
+
9
+ import click
10
+ import requests
11
+
12
+ ATTACHMENT_EXTENSIONS = {
13
+ ".png", ".jpg", ".jpeg", ".gif", ".svg", ".mp4", ".webm",
14
+ }
15
+ ATTACHMENT_SUFFIXES = {"-attachment.json"}
16
+
17
+ DEFAULT_HOST = "https://save-test.ptnl.moscow"
18
+ DEFAULT_CHUNK_SIZE_MB = 50
19
+ DEFAULT_POLL_INTERVAL_SEC = 1.5
20
+ DEFAULT_WAIT_TIMEOUT_SEC = 3600
21
+ TERMINAL_STATUSES = frozenset({"completed", "partial", "failed", "cancelled"})
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Helpers
26
+ # ---------------------------------------------------------------------------
27
+
28
+ def _is_attachment(name: str) -> bool:
29
+ p = Path(name)
30
+ if p.suffix.lower() in ATTACHMENT_EXTENSIONS:
31
+ return True
32
+ for s in ATTACHMENT_SUFFIXES:
33
+ if p.name.endswith(s):
34
+ return True
35
+ return False
36
+
37
+
38
+ def _sizeof_fmt(num: int) -> str:
39
+ for unit in ("B", "KB", "MB", "GB"):
40
+ if num < 1024:
41
+ return f"{num:.1f} {unit}"
42
+ num /= 1024
43
+ return f"{num:.1f} TB"
44
+
45
+
46
+ def _poll_import_task(
47
+ host: str,
48
+ project_id: str,
49
+ task_id: str,
50
+ headers: Dict[str, str],
51
+ poll_interval: float,
52
+ timeout_sec: int,
53
+ ) -> dict[str, Any]:
54
+ """Ожидает завершения фонового импорта (как UI SaveTest)."""
55
+ url = f"{host}/api/v1/projects/{project_id}/allure-results/tasks/{task_id}"
56
+ deadline = time.monotonic() + timeout_sec
57
+ poll_errors = 0
58
+ max_poll_errors = 10
59
+ last_progress = -1
60
+
61
+ click.echo(f"\nОжидание обработки (task_id: {task_id})...")
62
+
63
+ while time.monotonic() < deadline:
64
+ try:
65
+ resp = requests.get(url, headers=headers, timeout=60)
66
+ _check_response(resp, "task status")
67
+ status = resp.json()
68
+ poll_errors = 0
69
+ except SystemExit:
70
+ raise
71
+ except requests.RequestException as exc:
72
+ poll_errors += 1
73
+ if poll_errors >= max_poll_errors:
74
+ click.echo(
75
+ f"\nНе удалось получить статус задачи после {max_poll_errors} попыток: {exc}",
76
+ err=True,
77
+ )
78
+ sys.exit(1)
79
+ time.sleep(poll_interval)
80
+ continue
81
+
82
+ state = status.get("status", "")
83
+ progress = status.get("progress") or 0
84
+
85
+ if progress != last_progress:
86
+ click.echo(f" [{state}] прогресс: {progress}%")
87
+ last_progress = progress
88
+
89
+ if state in TERMINAL_STATUSES:
90
+ return status
91
+
92
+ time.sleep(poll_interval)
93
+
94
+ click.echo(f"\nТаймаут ожидания ({timeout_sec} с). Задача ещё выполняется: {task_id}", err=True)
95
+ sys.exit(1)
96
+
97
+
98
+ def _print_import_result(host: str, project_id: str, status: dict[str, Any]) -> None:
99
+ state = status.get("status", "")
100
+ test_run_id = status.get("test_run_id")
101
+ test_run_name = status.get("test_run_name")
102
+
103
+ click.echo("\n--- Итог импорта ---")
104
+ click.echo(f"Статус: {state}")
105
+ if test_run_id is not None:
106
+ click.echo(f"Тест-ран ID: {test_run_id}")
107
+ click.echo(f"Тест-ран: {test_run_name or '—'}")
108
+ click.echo(f"Ссылка: {host}/projects/{project_id}/test-runs/{test_run_id}")
109
+ click.echo(f"Обработано: {status.get('results_processed', 0)}")
110
+ click.echo(f"Создано: {status.get('results_created', 0)}")
111
+ click.echo(f"Сопоставлено: {status.get('test_cases_matched', 0)}")
112
+ click.echo(f"Пропущено: {status.get('skipped', 0)}")
113
+
114
+ errors = status.get("errors") or []
115
+ if errors:
116
+ click.echo("Ошибки:")
117
+ for err in errors:
118
+ click.echo(f" - {err}")
119
+
120
+ if state == "completed":
121
+ click.echo("\nИмпорт завершён успешно.")
122
+ elif state == "partial":
123
+ click.echo("\nИмпорт завершён с ошибками (часть результатов создана).", err=True)
124
+ sys.exit(1)
125
+ elif state == "cancelled":
126
+ click.echo("\nИмпорт отменён.", err=True)
127
+ sys.exit(1)
128
+ else:
129
+ click.echo("\nИмпорт не удался.", err=True)
130
+ sys.exit(1)
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # CLI root
135
+ # ---------------------------------------------------------------------------
136
+
137
+ @click.group()
138
+ @click.version_option(package_name="savetest-cli")
139
+ def cli() -> None:
140
+ """SaveTest CLI — упаковка и загрузка Allure-результатов."""
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # zip_report
145
+ # ---------------------------------------------------------------------------
146
+
147
+ @cli.command("zip_report")
148
+ @click.argument("path", type=click.Path(exists=True, file_okay=False, resolve_path=True))
149
+ @click.option(
150
+ "--output", "-o",
151
+ default="allure-results.zip",
152
+ show_default=True,
153
+ help="Путь к выходному ZIP-файлу.",
154
+ )
155
+ @click.option(
156
+ "--include-attachments/--no-include-attachments",
157
+ default=False,
158
+ show_default=True,
159
+ help="Включать вложения (картинки, видео, *-attachment.json).",
160
+ )
161
+ def zip_report(path: str, output: str, include_attachments: bool) -> None:
162
+ """Упаковать директорию PATH с Allure-результатами в ZIP.
163
+
164
+ \b
165
+ Примеры:
166
+ savetest zip_report ./AllureReport
167
+ savetest zip_report ./AllureReport --output report.zip
168
+ savetest zip_report ./AllureReport --include-attachments
169
+ """
170
+ src = Path(path)
171
+ out = Path(output)
172
+
173
+ if out.exists():
174
+ out.unlink()
175
+
176
+ skipped = 0
177
+ added = 0
178
+
179
+ click.echo(f"Упаковка: {src} → {out}")
180
+
181
+ with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as zf:
182
+ for file_path in sorted(src.rglob("*")):
183
+ if not file_path.is_file():
184
+ continue
185
+
186
+ rel = file_path.relative_to(src.parent)
187
+
188
+ if not include_attachments and _is_attachment(file_path.name):
189
+ skipped += 1
190
+ continue
191
+
192
+ zf.write(file_path, rel)
193
+ added += 1
194
+
195
+ size = _sizeof_fmt(out.stat().st_size)
196
+ click.echo(f"Готово: добавлено {added} файлов, пропущено {skipped} вложений. Размер: {size}")
197
+ click.echo(f"Файл: {out.resolve()}")
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # upload
202
+ # ---------------------------------------------------------------------------
203
+
204
+ @cli.command("upload")
205
+ @click.option(
206
+ "--zip-file", "-z",
207
+ default="allure-results.zip",
208
+ show_default=True,
209
+ type=click.Path(exists=True, dir_okay=False, resolve_path=True),
210
+ help="Путь к ZIP-файлу с Allure-результатами.",
211
+ )
212
+ @click.option(
213
+ "--host",
214
+ default=DEFAULT_HOST,
215
+ show_default=True,
216
+ envvar="SAVETEST_HOST",
217
+ help="Хост SaveTest (или переменная окружения SAVETEST_HOST).",
218
+ )
219
+ @click.option(
220
+ "--token", "-t",
221
+ required=True,
222
+ envvar="SAVETEST_TOKEN",
223
+ help="API-токен SaveTest (или переменная окружения SAVETEST_TOKEN).",
224
+ )
225
+ @click.option(
226
+ "--project-id", "-p",
227
+ required=True,
228
+ envvar="SAVETEST_PROJECT_ID",
229
+ help="ID проекта в SaveTest (или переменная окружения SAVETEST_PROJECT_ID).",
230
+ )
231
+ @click.option(
232
+ "--test-run-id",
233
+ default="",
234
+ envvar="SAVETEST_TEST_RUN_ID",
235
+ help="ID существующего тест-рана (если не задан — создаётся новый).",
236
+ )
237
+ @click.option(
238
+ "--test-run-name",
239
+ default="",
240
+ envvar="SAVETEST_TEST_RUN_NAME",
241
+ help="Имя нового тест-рана (используется, если --test-run-id не задан).",
242
+ )
243
+ @click.option(
244
+ "--branch", "-b",
245
+ default="",
246
+ envvar="SAVETEST_BRANCH",
247
+ help="Ветка Git для тест-рана.",
248
+ )
249
+ @click.option(
250
+ "--include-attachments/--no-include-attachments",
251
+ default=False,
252
+ show_default=True,
253
+ envvar="SAVETEST_INCLUDE_ATTACHMENTS",
254
+ help="Сообщить серверу, что ZIP содержит вложения.",
255
+ )
256
+ @click.option(
257
+ "--chunk-size",
258
+ default=DEFAULT_CHUNK_SIZE_MB,
259
+ show_default=True,
260
+ type=int,
261
+ help="Размер чанка при загрузке (МБ).",
262
+ )
263
+ @click.option(
264
+ "--wait/--no-wait",
265
+ default=True,
266
+ show_default=True,
267
+ help="Дождаться полной обработки импорта на сервере.",
268
+ )
269
+ @click.option(
270
+ "--poll-interval",
271
+ default=DEFAULT_POLL_INTERVAL_SEC,
272
+ show_default=True,
273
+ type=float,
274
+ help="Интервал опроса статуса задачи (сек).",
275
+ )
276
+ @click.option(
277
+ "--timeout",
278
+ default=DEFAULT_WAIT_TIMEOUT_SEC,
279
+ show_default=True,
280
+ type=int,
281
+ help="Максимальное время ожидания обработки (сек).",
282
+ )
283
+ def upload(
284
+ zip_file: str,
285
+ host: str,
286
+ token: str,
287
+ project_id: str,
288
+ test_run_id: str,
289
+ test_run_name: str,
290
+ branch: str,
291
+ include_attachments: bool,
292
+ chunk_size: int,
293
+ wait: bool,
294
+ poll_interval: float,
295
+ timeout: int,
296
+ ) -> None:
297
+ """Загрузить ZIP с Allure-результатами в SaveTest и дождаться импорта.
298
+
299
+ \b
300
+ После загрузки чанков CLI опрашивает статус задачи, пока не создастся
301
+ тест-ран и не завершится импорт (по умолчанию --wait).
302
+
303
+ \b
304
+ Токен можно передать через переменную окружения SAVETEST_TOKEN.
305
+
306
+ \b
307
+ Примеры:
308
+ savetest upload --token sat_xxx --project-id 40
309
+ savetest upload --token sat_xxx --project-id 40 --test-run-id 7
310
+ savetest upload --token sat_xxx --project-id 40 --test-run-name "Smoke DEV" --branch dev
311
+ SAVETEST_TOKEN=sat_xxx savetest upload --project-id 40
312
+ """
313
+ host = host.rstrip("/")
314
+ chunk_bytes = chunk_size * 1024 * 1024
315
+ zip_path = Path(zip_file)
316
+ zip_size = zip_path.stat().st_size
317
+ total_chunks = math.ceil(zip_size / chunk_bytes)
318
+
319
+ click.echo(f"Файл: {zip_path} ({_sizeof_fmt(zip_size)})")
320
+ click.echo(f"Хост: {host}")
321
+ click.echo(f"Проект: {project_id}")
322
+ click.echo(f"Чанков: {total_chunks} × {chunk_size} МБ")
323
+
324
+ headers = {"Authorization": f"Bearer {token}"}
325
+
326
+ # --- INIT ---
327
+ init_data: dict = {
328
+ "filename": "allure-results.zip",
329
+ "total_chunks": str(total_chunks),
330
+ "chunk_size": str(chunk_bytes),
331
+ "git_branch": branch,
332
+ "include_attachments": "true" if include_attachments else "false",
333
+ }
334
+ if test_run_id:
335
+ init_data["test_run_id"] = test_run_id
336
+ elif test_run_name:
337
+ init_data["test_run_name"] = test_run_name
338
+
339
+ click.echo("\nИнициализация загрузки...")
340
+ resp = requests.post(
341
+ f"{host}/api/v1/projects/{project_id}/allure-results/upload/init",
342
+ headers=headers,
343
+ data=init_data,
344
+ timeout=60,
345
+ )
346
+ _check_response(resp, "init")
347
+
348
+ upload_id = resp.json()["upload_id"]
349
+ click.echo(f"Upload ID: {upload_id}")
350
+
351
+ # --- CHUNKS ---
352
+ click.echo("\nЗагрузка чанков...")
353
+ with open(zip_path, "rb") as fh:
354
+ for i in range(total_chunks):
355
+ chunk_data = fh.read(chunk_bytes)
356
+ resp = requests.put(
357
+ f"{host}/api/v1/projects/{project_id}/allure-results/upload/{upload_id}/chunk",
358
+ headers=headers,
359
+ data={"chunk_index": str(i)},
360
+ files={"file": (f"part_{i:06d}", chunk_data, "application/octet-stream")},
361
+ timeout=120,
362
+ )
363
+ _check_response(resp, f"chunk {i}")
364
+ click.echo(f" [{i + 1}/{total_chunks}] ✓ {_sizeof_fmt(len(chunk_data))}")
365
+
366
+ # --- COMPLETE ---
367
+ click.echo("\nЗавершение загрузки...")
368
+ resp = requests.post(
369
+ f"{host}/api/v1/projects/{project_id}/allure-results/upload/{upload_id}/complete",
370
+ headers=headers,
371
+ timeout=60,
372
+ )
373
+ _check_response(resp, "complete")
374
+
375
+ result = resp.json()
376
+ task_id = result.get("task_id")
377
+ if not task_id:
378
+ click.echo("Чанки приняты, но task_id не получен:", err=True)
379
+ click.echo(json.dumps(result, ensure_ascii=False, indent=2))
380
+ sys.exit(1)
381
+
382
+ click.echo(f"Чанки загружены. Задача импорта: {task_id}")
383
+
384
+ if not wait:
385
+ click.echo(json.dumps(result, ensure_ascii=False, indent=2))
386
+ click.echo("\nИмпорт выполняется в фоне. Используйте --wait или проверьте задачу в UI.")
387
+ return
388
+
389
+ final = _poll_import_task(
390
+ host, project_id, task_id, headers, poll_interval, timeout
391
+ )
392
+ _print_import_result(host, project_id, final)
393
+
394
+
395
+ def _check_response(resp: "requests.Response", step: str) -> None:
396
+ if not resp.ok:
397
+ click.echo(
398
+ f"\nОшибка на шаге '{step}': HTTP {resp.status_code}\n{resp.text}",
399
+ err=True,
400
+ )
401
+ sys.exit(1)
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: savetest-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for SaveTest: zip Allure results and upload them to SaveTest
5
+ License: MIT
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: click>=8.0
9
+ Requires-Dist: requests>=2.28
10
+
11
+ # savetest-cli
12
+
13
+ CLI-утилита для упаковки Allure-результатов в ZIP и загрузки их в [SaveTest](https://save-test.ru).
14
+
15
+ ## Установка
16
+
17
+ ```bash
18
+ pip install savetest-cli
19
+ ```
20
+
21
+ ## Команды
22
+
23
+ ### `savetest zip_report <path>`
24
+
25
+ Упаковывает директорию с Allure-результатами в ZIP-архив.
26
+
27
+ ```bash
28
+ savetest zip_report ./AllureReport
29
+ ```
30
+
31
+ | Параметр | По умолчанию | Описание |
32
+ |---|---|---|
33
+ | `path` | — | Путь к директории с Allure-результатами |
34
+ | `--output`, `-o` | `allure-results.zip` | Путь к выходному ZIP-файлу |
35
+ | `--include-attachments` | `false` | Включить вложения (картинки, видео, `*-attachment.json`) |
36
+ | `--no-include-attachments` | — | Исключить вложения (быстрее, меньше объём) |
37
+
38
+ **Примеры:**
39
+
40
+ ```bash
41
+ # Упаковать без вложений (быстрее)
42
+ savetest zip_report ./AllureReport
43
+
44
+ # Указать выходной файл
45
+ savetest zip_report ./AllureReport --output my-report.zip
46
+
47
+ # Включить вложения
48
+ savetest zip_report ./AllureReport --include-attachments
49
+ ```
50
+
51
+ ---
52
+
53
+ ### `savetest upload`
54
+
55
+ Загружает ZIP-архив в SaveTest по частям (чанками).
56
+
57
+ ```bash
58
+ savetest upload --token sat_xxx --project-id 40
59
+ ```
60
+
61
+ | Параметр | Env-переменная | По умолчанию | Описание |
62
+ |---|---|---|---|
63
+ | `--zip-file`, `-z` | — | `allure-results.zip` | Путь к ZIP-файлу |
64
+ | `--host` | `SAVETEST_HOST` | `https://save-test.ptnl.moscow` | Хост SaveTest |
65
+ | `--token`, `-t` | `SAVETEST_TOKEN` | — | API-токен (**обязательно**) |
66
+ | `--project-id`, `-p` | `SAVETEST_PROJECT_ID` | — | ID проекта (**обязательно**) |
67
+ | `--test-run-id` | `SAVETEST_TEST_RUN_ID` | — | ID существующего тест-рана (иначе создаётся новый) |
68
+ | `--test-run-name` | `SAVETEST_TEST_RUN_NAME` | — | Имя нового тест-рана |
69
+ | `--branch`, `-b` | `SAVETEST_BRANCH` | — | Ветка Git |
70
+ | `--include-attachments` | `SAVETEST_INCLUDE_ATTACHMENTS` | `false` | Сообщить серверу, что ZIP содержит вложения |
71
+ | `--chunk-size` | — | `50` | Размер чанка при загрузке (МБ) |
72
+ | `--wait` / `--no-wait` | — | `--wait` | Дождаться завершения импорта и создания тест-рана |
73
+ | `--poll-interval` | — | `1.5` | Интервал опроса статуса (сек) |
74
+ | `--timeout` | — | `3600` | Таймаут ожидания обработки (сек) |
75
+
76
+ **Примеры:**
77
+
78
+ ```bash
79
+ # Минимальный вызов (токен через переменную окружения)
80
+ export SAVETEST_TOKEN=sat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
81
+ savetest upload --project-id 40
82
+
83
+ # Создать новый тест-ран с именем
84
+ savetest upload \
85
+ --token sat_xxx \
86
+ --project-id 40 \
87
+ --test-run-name "Smoke DEV #42" \
88
+ --branch dev
89
+
90
+ # Загрузить в существующий тест-ран
91
+ savetest upload \
92
+ --token sat_xxx \
93
+ --project-id 40 \
94
+ --test-run-id 7
95
+
96
+ # Загрузить с вложениями из другого файла
97
+ savetest upload \
98
+ --token sat_xxx \
99
+ --project-id 40 \
100
+ --zip-file my-report.zip \
101
+ --include-attachments
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Типичный сценарий
107
+
108
+ ```bash
109
+ # 1. Упаковать результаты (без вложений)
110
+ savetest zip_report ./AllureReport
111
+
112
+ # 2. Загрузить в SaveTest (CLI дождётся импорта и выведет ссылку на тест-ран)
113
+ export SAVETEST_TOKEN=sat_xxx
114
+ savetest upload --project-id 40 --branch dev --test-run-name "CI build #123"
115
+ ```
116
+
117
+ По умолчанию `upload` ждёт полной обработки. Чтобы вернуться сразу после приёма чанков: `--no-wait`.
118
+
119
+ ## Соответствие параметрам Jenkins Pipeline
120
+
121
+ | Jenkins-параметр | CLI-опция |
122
+ |---|---|
123
+ | `savetest_host` | `--host` |
124
+ | `savetest_project_id` | `--project-id` |
125
+ | `savetest_test_run_id` | `--test-run-id` |
126
+ | `savetest_include_attachments` | `--include-attachments` / `--no-include-attachments` |
127
+ | `branch` | `--branch` |
128
+ | токен (захардкожен) | `--token` / `SAVETEST_TOKEN` |
@@ -0,0 +1,7 @@
1
+ savetest_cli/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ savetest_cli/cli.py,sha256=gHvru0TVaaWdOx6iNI8f3lDTo2MksljWlNnlZTV2dG0,13518
3
+ savetest_cli-0.1.0.dist-info/METADATA,sha256=nZ7AULbGVoFlO573kLc28dQnwdlzglTEKl1DSFZgcXs,4775
4
+ savetest_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ savetest_cli-0.1.0.dist-info/entry_points.txt,sha256=MxXyo6HuUJnAgVKz2dERTMj2-gE63oLZ_zIWj0WV3uE,50
6
+ savetest_cli-0.1.0.dist-info/top_level.txt,sha256=xwNY_SqftXhmx2sSB6hVJ5ukfsfxTugV8vyApyu0TLM,13
7
+ savetest_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ savetest = savetest_cli.cli:cli
@@ -0,0 +1 @@
1
+ savetest_cli