savetest-cli 0.3.0rc1__tar.gz → 0.3.0rc3__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: savetest-cli
3
- Version: 0.3.0rc1
3
+ Version: 0.3.0rc3
4
4
  Summary: CLI for SaveTest: upload Allure results as a ZIP, as raw files, or by watching a directory
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.8
@@ -220,7 +220,7 @@ savetest watch ./allure-results \
220
220
  - Если тесты и загрузка — разные джобы, отдайте каталог `allure-results` артефактом тоже с `when: always`.
221
221
  - `--wait` оставляйте включённым: джоба должна завершиться только когда импорт дошёл до конца.
222
222
  - Вложения включайте только если они реально нужны. По умолчанию CLI их не шлёт.
223
- - `0.3.0rc1` — предварительная версия для SaveTest 1.5.0: `pip install savetest-cli==0.3.0rc1`. Обычный `pip install savetest-cli` остаётся на последней стабильной версии.
223
+ - `0.3.0rc3` — предварительная версия для SaveTest 1.5.0: `pip install savetest-cli==0.3.0rc3`. Обычный `pip install savetest-cli` остаётся на последней стабильной версии.
224
224
 
225
225
  ### Результаты по мере прогона
226
226
 
@@ -210,7 +210,7 @@ savetest watch ./allure-results \
210
210
  - Если тесты и загрузка — разные джобы, отдайте каталог `allure-results` артефактом тоже с `when: always`.
211
211
  - `--wait` оставляйте включённым: джоба должна завершиться только когда импорт дошёл до конца.
212
212
  - Вложения включайте только если они реально нужны. По умолчанию CLI их не шлёт.
213
- - `0.3.0rc1` — предварительная версия для SaveTest 1.5.0: `pip install savetest-cli==0.3.0rc1`. Обычный `pip install savetest-cli` остаётся на последней стабильной версии.
213
+ - `0.3.0rc3` — предварительная версия для SaveTest 1.5.0: `pip install savetest-cli==0.3.0rc3`. Обычный `pip install savetest-cli` остаётся на последней стабильной версии.
214
214
 
215
215
  ### Результаты по мере прогона
216
216
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "savetest-cli"
7
- version = "0.3.0rc1"
7
+ version = "0.3.0rc3"
8
8
  description = "CLI for SaveTest: upload Allure results as a ZIP, as raw files, or by watching a directory"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0rc3"
@@ -22,11 +22,13 @@ DEFAULT_POLL_INTERVAL_SEC = 1.5
22
22
  DEFAULT_WAIT_TIMEOUT_SEC = 3600
23
23
  DEFAULT_FILE_BATCH_COUNT = 20
24
24
  DEFAULT_FILE_BATCH_BYTES = 32 * 1024 * 1024
25
+ DEFAULT_WATCH_BATCH_COUNT = 200
25
26
  DEFAULT_WATCH_INTERVAL_SEC = 0.5
26
27
  DEFAULT_SETTLE_SEC = 1.0
27
28
  DEFAULT_ATTACHMENT_WAIT_SEC = 5.0
28
29
  DEFAULT_WATCH_BATCH_WINDOW_SEC = 1.0
29
30
  DEFAULT_WATCH_BATCH_MAX_WAIT_SEC = 5.0
31
+ DEFAULT_WATCH_HEARTBEAT_SEC = 15
30
32
  TERMINAL_STATUSES = frozenset({"completed", "partial", "failed", "cancelled"})
31
33
  _JUNK_NAMES = {".ds_store", "thumbs.db", ".project_id", ".gitkeep"}
32
34
 
@@ -825,26 +827,6 @@ def upload_files(
825
827
  # watch — каталог в реальном времени
826
828
  # ---------------------------------------------------------------------------
827
829
 
828
- def _attachment_sources(data: Any) -> List[str]:
829
- names: List[str] = []
830
-
831
- def walk(node: Any) -> None:
832
- if isinstance(node, dict):
833
- source = node.get("source")
834
- if isinstance(source, str) and source:
835
- names.append(Path(source).name)
836
- for value in node.values():
837
- walk(value)
838
- elif isinstance(node, list):
839
- for value in node:
840
- walk(value)
841
-
842
- if isinstance(data, dict):
843
- walk(data.get("attachments"))
844
- walk(data.get("steps"))
845
- return names
846
-
847
-
848
830
  def _load_json_if_complete(path: Path) -> Optional[Any]:
849
831
  try:
850
832
  with open(path, "r", encoding="utf-8") as handle:
@@ -868,22 +850,6 @@ def _snapshot_raw_files(root: Path, include_attachments: bool) -> Dict[str, Tupl
868
850
  return found
869
851
 
870
852
 
871
- def _resolve_named_file(
872
- snapshot: Dict[str, Tuple[Path, int, int]],
873
- result_rel: str,
874
- source_name: str,
875
- ) -> Optional[str]:
876
- name = Path(source_name).name
877
- result_dir = str(Path(result_rel).parent)
878
- matches = [rel for rel in snapshot if Path(rel).name == name]
879
- if not matches:
880
- return None
881
- for rel in matches:
882
- if str(Path(rel).parent) == result_dir:
883
- return rel
884
- return matches[0]
885
-
886
-
887
853
  def _pick_watch_batch(
888
854
  snapshot: Dict[str, Tuple[Path, int, int]],
889
855
  stable_since: Dict[str, Tuple[int, int, float]],
@@ -895,11 +861,8 @@ def _pick_watch_batch(
895
861
  *,
896
862
  flushing: bool,
897
863
  ) -> List[Tuple[str, Path]]:
898
- """Файлы, которые уже дописаны и их можно отправить одной пачкой.
899
-
900
- Результат ждёт свои вложения не дольше attachment_wait_sec, чтобы не уехать
901
- раньше скриншота. Пачка без *-result.json не отправляется: сервер её не примет.
902
- """
864
+ """Файлы, которые уже дописаны. Результат и вложение уходят по отдельности."""
865
+ del include_attachments, attachment_wait_sec
903
866
  ready: Dict[str, Path] = {}
904
867
  for rel in list(stable_since):
905
868
  if rel not in snapshot:
@@ -921,49 +884,7 @@ def _pick_watch_batch(
921
884
  continue
922
885
  ready[rel] = path
923
886
 
924
- held = set()
925
- if include_attachments:
926
- for rel, path in list(ready.items()):
927
- if not _is_result_file(path):
928
- continue
929
- document = _load_json_if_complete(path) or {}
930
- missing = False
931
- for source in _attachment_sources(document):
932
- attachment_rel = _resolve_named_file(snapshot, rel, source)
933
- if attachment_rel is None or attachment_rel not in ready:
934
- missing = True
935
- break
936
- if not missing:
937
- continue
938
- ready_since = stable_since.get(rel, (0, 0, now))[2]
939
- if not flushing and (now - ready_since) < settle_sec + attachment_wait_sec:
940
- held.add(rel)
941
- else:
942
- click.echo(f"Результат {rel} отправляю без части вложений: они так и не появились.")
943
-
944
- send_results = [
945
- (rel, path) for rel, path in ready.items()
946
- if _is_result_file(path) and rel not in held
947
- ]
948
- if not send_results:
949
- return []
950
-
951
- needed = set()
952
- for rel, path in send_results:
953
- document = _load_json_if_complete(path) or {}
954
- for source in _attachment_sources(document):
955
- attachment_rel = _resolve_named_file(snapshot, rel, source)
956
- if attachment_rel and attachment_rel in ready:
957
- needed.add(attachment_rel)
958
-
959
- selected = list(send_results)
960
- for rel, path in ready.items():
961
- if _is_result_file(path):
962
- continue
963
- if include_attachments and _is_attachment(path.name) and rel not in needed:
964
- continue
965
- selected.append((rel, path))
966
- return selected
887
+ return [(rel, path) for rel, path in ready.items()]
967
888
 
968
889
 
969
890
  def _watch_batch_due(
@@ -1002,25 +923,7 @@ def _chunk_watch_selection(
1002
923
  max_count: int = DEFAULT_FILE_BATCH_COUNT,
1003
924
  max_bytes: int = DEFAULT_FILE_BATCH_BYTES,
1004
925
  ) -> List[List[Tuple[str, Path]]]:
1005
- """Режет готовые файлы на пачки. Результат не отрывается от своих вложений."""
1006
- results = [(rel, path) for rel, path in selected if _is_result_file(path)]
1007
- extras = {rel: (rel, path) for rel, path in selected if not _is_result_file(path)}
1008
- used = set()
1009
- groups: List[List[Tuple[str, Path]]] = []
1010
- for rel, path in results:
1011
- group = [(rel, path)]
1012
- document = _load_json_if_complete(path) or {}
1013
- for source in _attachment_sources(document):
1014
- name = Path(source).name
1015
- for extra_rel, extra in extras.items():
1016
- if extra_rel in used or Path(extra_rel).name != name:
1017
- continue
1018
- group.append(extra)
1019
- used.add(extra_rel)
1020
- break
1021
- groups.append(group)
1022
-
1023
- loose = [extra for rel, extra in extras.items() if rel not in used]
926
+ """Режет готовые файлы на пачки по числу и размеру, без разбора содержимого."""
1024
927
  chunks: List[List[Tuple[str, Path]]] = []
1025
928
  current: List[Tuple[str, Path]] = []
1026
929
  current_bytes = 0
@@ -1032,19 +935,16 @@ def _chunk_watch_selection(
1032
935
  current = []
1033
936
  current_bytes = 0
1034
937
 
1035
- for group in groups:
1036
- group_bytes = _selection_bytes(group)
1037
- if current and (
1038
- len(current) + len(group) > max_count
1039
- or current_bytes + group_bytes > max_bytes
1040
- ):
938
+ for item in selected:
939
+ _rel, path = item
940
+ try:
941
+ size = path.stat().st_size
942
+ except OSError:
943
+ size = 0
944
+ if current and (len(current) >= max_count or current_bytes + size > max_bytes):
1041
945
  flush()
1042
- if loose and not current:
1043
- current.extend(loose)
1044
- current_bytes += _selection_bytes(loose)
1045
- loose = []
1046
- current.extend(group)
1047
- current_bytes += group_bytes
946
+ current.append(item)
947
+ current_bytes += size
1048
948
  if len(current) >= max_count or current_bytes >= max_bytes:
1049
949
  flush()
1050
950
  flush()
@@ -1135,6 +1035,64 @@ def _fetch_task(host: str, project_id: str, task_id: str, token: str) -> Dict[st
1135
1035
  return resp.json()
1136
1036
 
1137
1037
 
1038
+ def _watch_session_url(host: str, project_id: str, session_id: Optional[str] = None, action: str = "") -> str:
1039
+ url = f"{host}/api/v1/projects/{project_id}/allure-results/watch/sessions"
1040
+ if session_id:
1041
+ url = f"{url}/{session_id}"
1042
+ if action:
1043
+ url = f"{url}/{action}"
1044
+ return url
1045
+
1046
+
1047
+ def _post_watch_session(
1048
+ host: str,
1049
+ project_id: str,
1050
+ token: str,
1051
+ payload: Dict[str, Any],
1052
+ session_id: Optional[str] = None,
1053
+ action: str = "",
1054
+ ) -> Optional[Dict[str, Any]]:
1055
+ """Сигнал платформе. Ошибка здесь не останавливает тесты."""
1056
+ try:
1057
+ resp = requests.post(
1058
+ _watch_session_url(host, project_id, session_id, action),
1059
+ headers={"Authorization": f"Bearer {token}"},
1060
+ json=payload,
1061
+ timeout=20,
1062
+ )
1063
+ except requests.RequestException:
1064
+ return None
1065
+ if resp.status_code >= 400:
1066
+ return None
1067
+ try:
1068
+ body = resp.json()
1069
+ except ValueError:
1070
+ return None
1071
+ return body if isinstance(body, dict) else None
1072
+
1073
+
1074
+ def _watch_session_payload(
1075
+ *,
1076
+ test_run_id: Optional[str],
1077
+ test_run_name: Optional[str],
1078
+ stats: Dict[str, int],
1079
+ status: Optional[str] = None,
1080
+ ) -> Dict[str, Any]:
1081
+ payload: Dict[str, Any] = {
1082
+ "results_processed": stats["processed"],
1083
+ "results_created": stats["created"],
1084
+ "test_cases_matched": stats["matched"],
1085
+ "skipped": stats["skipped"],
1086
+ }
1087
+ if test_run_name:
1088
+ payload["test_run_name"] = test_run_name
1089
+ if test_run_id and str(test_run_id).isdigit():
1090
+ payload["test_run_id"] = int(test_run_id)
1091
+ if status:
1092
+ payload["status"] = status
1093
+ return payload
1094
+
1095
+
1138
1096
  @cli.command(
1139
1097
  "watch",
1140
1098
  context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
@@ -1147,7 +1105,7 @@ def _fetch_task(host: str, project_id: str, task_id: str, token: str) -> Dict[st
1147
1105
  )
1148
1106
  @click.option("--interval", default=DEFAULT_WATCH_INTERVAL_SEC, show_default=True, type=float, help="Как часто смотреть каталог (сек).")
1149
1107
  @click.option("--settle", default=DEFAULT_SETTLE_SEC, show_default=True, type=float, help="Сколько секунд размер файла не должен меняться, прежде чем его забрать.")
1150
- @click.option("--attachment-wait", default=DEFAULT_ATTACHMENT_WAIT_SEC, show_default=True, type=float, help="Сколько ждать вложение, на которое ссылается свежий *-result.json.")
1108
+ @click.option("--attachment-wait", default=DEFAULT_ATTACHMENT_WAIT_SEC, show_default=True, type=float, help="Не используется: файл уходит, как только перестал меняться.")
1151
1109
  @click.option("--batch-window", default=DEFAULT_WATCH_BATCH_WINDOW_SEC, show_default=True, type=float, help="Сколько секунд копить готовые файлы после последнего нового, прежде чем отправить пачку.")
1152
1110
  @click.option("--batch-max-wait", default=DEFAULT_WATCH_BATCH_MAX_WAIT_SEC, show_default=True, type=float, help="Дольше этого готовые файлы не держатся, даже если новые всё ещё появляются. 0 — ждать только тишины.")
1153
1111
  @click.option("--idle-timeout", default=0, show_default=True, type=int, help="Выйти, если столько секунд нет новых файлов и загрузок. 0 — не выходить по тишине.")
@@ -1193,8 +1151,8 @@ def watch(
1193
1151
 
1194
1152
  Готовые файлы не уходят по одному. Если они появляются подряд, watch держит их
1195
1153
  --batch-window секунд после последнего нового и отправляет вместе. Дольше
1196
- --batch-max-wait секунд пачка не копится. Предел одной отправки — 20 файлов
1197
- или 32 МБ, как у upload_results.
1154
+ --batch-max-wait секунд пачка не копится. Предел одной отправки — 200 файлов
1155
+ или 32 МБ.
1198
1156
  """
1199
1157
  command = [arg for arg in ctx.args if arg != "--"]
1200
1158
  host = host.rstrip("/")
@@ -1209,6 +1167,7 @@ def watch(
1209
1167
  run_create_failed = False
1210
1168
  sent_batches = 0
1211
1169
  drain_until = 0.0
1170
+ announced_flush = False
1212
1171
  hold_opened = 0.0
1213
1172
  hold_quiet = 0.0
1214
1173
  hold_key: Optional[Tuple[str, ...]] = None
@@ -1250,6 +1209,21 @@ def watch(
1250
1209
  click.echo(f"Также выйду после {idle_timeout} с тишины.")
1251
1210
 
1252
1211
  watch_code = 0
1212
+ session_id = None
1213
+ session_stats = {"processed": 0, "created": 0, "matched": 0, "skipped": 0}
1214
+ last_heartbeat = 0.0
1215
+ session_dirty = False
1216
+ open_payload: Dict[str, Any] = {}
1217
+ if test_run_name:
1218
+ open_payload["test_run_name"] = test_run_name
1219
+ if test_run_id and str(test_run_id).isdigit():
1220
+ open_payload["test_run_id"] = int(test_run_id)
1221
+ opened = _post_watch_session(host, project_id, token, open_payload)
1222
+ if opened and opened.get("task_id"):
1223
+ session_id = str(opened["task_id"])
1224
+ click.echo(f"Платформа показывает загрузку: {session_id}")
1225
+ else:
1226
+ click.echo("Карточка загрузки на платформе не открылась. Файлы всё равно будут отправлены.", err=True)
1253
1227
  try:
1254
1228
  if command:
1255
1229
  child["proc"] = subprocess.Popen(
@@ -1267,6 +1241,10 @@ def watch(
1267
1241
  flushing = stop["requested"] or timed_out
1268
1242
 
1269
1243
  snapshot = _snapshot_raw_files(root, include_attachments) if root.is_dir() else {}
1244
+ if flushing and not announced_flush:
1245
+ pending = sum(1 for rel in snapshot if rel not in uploaded)
1246
+ click.echo(f"После остановки осталось файлов: {pending}.")
1247
+ announced_flush = True
1270
1248
  if any(rel not in uploaded for rel in snapshot):
1271
1249
  last_activity = now
1272
1250
 
@@ -1300,7 +1278,7 @@ def watch(
1300
1278
  hold_key = ready_key
1301
1279
 
1302
1280
  batch_full = bool(batch) and (
1303
- len(batch) >= DEFAULT_FILE_BATCH_COUNT
1281
+ len(batch) >= DEFAULT_WATCH_BATCH_COUNT
1304
1282
  or _selection_bytes(batch) >= DEFAULT_FILE_BATCH_BYTES
1305
1283
  )
1306
1284
  send_now = bool(batch) and _watch_batch_due(
@@ -1313,7 +1291,10 @@ def watch(
1313
1291
  flushing=flushing,
1314
1292
  )
1315
1293
  if send_now and not run_create_failed and (known_run_id or not inflight):
1316
- chunks = _chunk_watch_selection(batch)
1294
+ chunks = _chunk_watch_selection(
1295
+ batch,
1296
+ max_count=DEFAULT_WATCH_BATCH_COUNT,
1297
+ )
1317
1298
  if not known_run_id:
1318
1299
  chunks = chunks[:1]
1319
1300
  for chunk in chunks:
@@ -1366,6 +1347,11 @@ def watch(
1366
1347
  f"создано {status.get('results_created', 0)}, "
1367
1348
  f"обработано {status.get('results_processed', 0)}"
1368
1349
  )
1350
+ session_stats["processed"] += int(status.get("results_processed") or 0)
1351
+ session_stats["created"] += int(status.get("results_created") or 0)
1352
+ session_stats["matched"] += int(status.get("test_cases_matched") or 0)
1353
+ session_stats["skipped"] += int(status.get("skipped") or 0)
1354
+ session_dirty = True
1369
1355
  if state != "completed":
1370
1356
  had_failure = True
1371
1357
  for err in (status.get("errors") or [])[:3]:
@@ -1375,6 +1361,26 @@ def watch(
1375
1361
  click.echo("Тест-ран не создан, новые пачки не отправляю.", err=True)
1376
1362
  inflight = still_running
1377
1363
 
1364
+ if session_id and (
1365
+ now - last_heartbeat >= DEFAULT_WATCH_HEARTBEAT_SEC
1366
+ or (session_dirty and now - last_heartbeat >= 2)
1367
+ ):
1368
+ sent = _post_watch_session(
1369
+ host,
1370
+ project_id,
1371
+ token,
1372
+ _watch_session_payload(
1373
+ test_run_id=known_run_id,
1374
+ test_run_name=test_run_name,
1375
+ stats=session_stats,
1376
+ ),
1377
+ session_id=session_id,
1378
+ action="heartbeat",
1379
+ )
1380
+ last_heartbeat = now
1381
+ if sent is not None:
1382
+ session_dirty = False
1383
+
1378
1384
  if run_create_failed and not inflight:
1379
1385
  click.echo("watch остановлен: первая загрузка не создала тест-ран.", err=True)
1380
1386
  watch_code = 1
@@ -1399,6 +1405,25 @@ def watch(
1399
1405
  time.sleep(interval)
1400
1406
  finally:
1401
1407
  _stop_child()
1408
+ if session_id:
1409
+ finish_status = "failed" if had_failure or run_create_failed else "completed"
1410
+ closed = _post_watch_session(
1411
+ host,
1412
+ project_id,
1413
+ token,
1414
+ _watch_session_payload(
1415
+ test_run_id=known_run_id,
1416
+ test_run_name=test_run_name,
1417
+ stats=session_stats,
1418
+ status=finish_status,
1419
+ ),
1420
+ session_id=session_id,
1421
+ action="complete",
1422
+ )
1423
+ if closed is not None:
1424
+ click.echo("Платформа закрыла карточку загрузки.")
1425
+ else:
1426
+ click.echo("Не удалось закрыть карточку загрузки на платформе.", err=True)
1402
1427
 
1403
1428
  test_status = 0
1404
1429
  if child["proc"] is not None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: savetest-cli
3
- Version: 0.3.0rc1
3
+ Version: 0.3.0rc3
4
4
  Summary: CLI for SaveTest: upload Allure results as a ZIP, as raw files, or by watching a directory
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.8
@@ -220,7 +220,7 @@ savetest watch ./allure-results \
220
220
  - Если тесты и загрузка — разные джобы, отдайте каталог `allure-results` артефактом тоже с `when: always`.
221
221
  - `--wait` оставляйте включённым: джоба должна завершиться только когда импорт дошёл до конца.
222
222
  - Вложения включайте только если они реально нужны. По умолчанию CLI их не шлёт.
223
- - `0.3.0rc1` — предварительная версия для SaveTest 1.5.0: `pip install savetest-cli==0.3.0rc1`. Обычный `pip install savetest-cli` остаётся на последней стабильной версии.
223
+ - `0.3.0rc3` — предварительная версия для SaveTest 1.5.0: `pip install savetest-cli==0.3.0rc3`. Обычный `pip install savetest-cli` остаётся на последней стабильной версии.
224
224
 
225
225
  ### Результаты по мере прогона
226
226
 
@@ -1 +0,0 @@
1
- __version__ = "0.3.0rc1"