savetest-cli 0.3.0rc2__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.0rc2
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.0rc2` — предварительная версия для SaveTest 1.5.0: `pip install savetest-cli==0.3.0rc2`. Обычный `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.0rc2` — предварительная версия для SaveTest 1.5.0: `pip install savetest-cli==0.3.0rc2`. Обычный `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.0rc2"
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"
@@ -28,6 +28,7 @@ DEFAULT_SETTLE_SEC = 1.0
28
28
  DEFAULT_ATTACHMENT_WAIT_SEC = 5.0
29
29
  DEFAULT_WATCH_BATCH_WINDOW_SEC = 1.0
30
30
  DEFAULT_WATCH_BATCH_MAX_WAIT_SEC = 5.0
31
+ DEFAULT_WATCH_HEARTBEAT_SEC = 15
31
32
  TERMINAL_STATUSES = frozenset({"completed", "partial", "failed", "cancelled"})
32
33
  _JUNK_NAMES = {".ds_store", "thumbs.db", ".project_id", ".gitkeep"}
33
34
 
@@ -826,26 +827,6 @@ def upload_files(
826
827
  # watch — каталог в реальном времени
827
828
  # ---------------------------------------------------------------------------
828
829
 
829
- def _attachment_sources(data: Any) -> List[str]:
830
- names: List[str] = []
831
-
832
- def walk(node: Any) -> None:
833
- if isinstance(node, dict):
834
- source = node.get("source")
835
- if isinstance(source, str) and source:
836
- names.append(Path(source).name)
837
- for value in node.values():
838
- walk(value)
839
- elif isinstance(node, list):
840
- for value in node:
841
- walk(value)
842
-
843
- if isinstance(data, dict):
844
- walk(data.get("attachments"))
845
- walk(data.get("steps"))
846
- return names
847
-
848
-
849
830
  def _load_json_if_complete(path: Path) -> Optional[Any]:
850
831
  try:
851
832
  with open(path, "r", encoding="utf-8") as handle:
@@ -869,36 +850,6 @@ def _snapshot_raw_files(root: Path, include_attachments: bool) -> Dict[str, Tupl
869
850
  return found
870
851
 
871
852
 
872
- def _index_snapshot_names(
873
- snapshot: Dict[str, Tuple[Path, int, int]],
874
- ) -> Dict[str, List[str]]:
875
- """Имя файла → пути. Сопоставление вложений не должно перебирать весь каталог."""
876
- index: Dict[str, List[str]] = {}
877
- for rel in snapshot:
878
- index.setdefault(Path(rel).name, []).append(rel)
879
- return index
880
-
881
-
882
- def _resolve_named_file(
883
- snapshot: Dict[str, Tuple[Path, int, int]],
884
- result_rel: str,
885
- source_name: str,
886
- name_index: Optional[Dict[str, List[str]]] = None,
887
- ) -> Optional[str]:
888
- name = Path(source_name).name
889
- result_dir = str(Path(result_rel).parent)
890
- if name_index is None:
891
- matches = [rel for rel in snapshot if Path(rel).name == name]
892
- else:
893
- matches = name_index.get(name) or []
894
- if not matches:
895
- return None
896
- for rel in matches:
897
- if str(Path(rel).parent) == result_dir:
898
- return rel
899
- return matches[0]
900
-
901
-
902
853
  def _pick_watch_batch(
903
854
  snapshot: Dict[str, Tuple[Path, int, int]],
904
855
  stable_since: Dict[str, Tuple[int, int, float]],
@@ -910,11 +861,8 @@ def _pick_watch_batch(
910
861
  *,
911
862
  flushing: bool,
912
863
  ) -> List[Tuple[str, Path]]:
913
- """Файлы, которые уже дописаны и их можно отправить одной пачкой.
914
-
915
- Результат ждёт свои вложения не дольше attachment_wait_sec, чтобы не уехать
916
- раньше скриншота. Пачка без *-result.json не отправляется: сервер её не примет.
917
- """
864
+ """Файлы, которые уже дописаны. Результат и вложение уходят по отдельности."""
865
+ del include_attachments, attachment_wait_sec
918
866
  ready: Dict[str, Path] = {}
919
867
  for rel in list(stable_since):
920
868
  if rel not in snapshot:
@@ -936,50 +884,7 @@ def _pick_watch_batch(
936
884
  continue
937
885
  ready[rel] = path
938
886
 
939
- name_index = _index_snapshot_names(snapshot)
940
- held = set()
941
- if include_attachments:
942
- for rel, path in list(ready.items()):
943
- if not _is_result_file(path):
944
- continue
945
- document = _load_json_if_complete(path) or {}
946
- missing = False
947
- for source in _attachment_sources(document):
948
- attachment_rel = _resolve_named_file(snapshot, rel, source, name_index)
949
- if attachment_rel is None or attachment_rel not in ready:
950
- missing = True
951
- break
952
- if not missing:
953
- continue
954
- ready_since = stable_since.get(rel, (0, 0, now))[2]
955
- if not flushing and (now - ready_since) < settle_sec + attachment_wait_sec:
956
- held.add(rel)
957
- else:
958
- click.echo(f"Результат {rel} отправляю без части вложений: они так и не появились.")
959
-
960
- send_results = [
961
- (rel, path) for rel, path in ready.items()
962
- if _is_result_file(path) and rel not in held
963
- ]
964
- if not send_results:
965
- return []
966
-
967
- needed = set()
968
- for rel, path in send_results:
969
- document = _load_json_if_complete(path) or {}
970
- for source in _attachment_sources(document):
971
- attachment_rel = _resolve_named_file(snapshot, rel, source, name_index)
972
- if attachment_rel and attachment_rel in ready:
973
- needed.add(attachment_rel)
974
-
975
- selected = list(send_results)
976
- for rel, path in ready.items():
977
- if _is_result_file(path):
978
- continue
979
- if include_attachments and _is_attachment(path.name) and rel not in needed:
980
- continue
981
- selected.append((rel, path))
982
- return selected
887
+ return [(rel, path) for rel, path in ready.items()]
983
888
 
984
889
 
985
890
  def _watch_batch_due(
@@ -1018,28 +923,7 @@ def _chunk_watch_selection(
1018
923
  max_count: int = DEFAULT_FILE_BATCH_COUNT,
1019
924
  max_bytes: int = DEFAULT_FILE_BATCH_BYTES,
1020
925
  ) -> List[List[Tuple[str, Path]]]:
1021
- """Режет готовые файлы на пачки. Результат не отрывается от своих вложений."""
1022
- results = [(rel, path) for rel, path in selected if _is_result_file(path)]
1023
- extras = {rel: (rel, path) for rel, path in selected if not _is_result_file(path)}
1024
- extras_by_name: Dict[str, List[str]] = {}
1025
- for extra_rel in extras:
1026
- extras_by_name.setdefault(Path(extra_rel).name, []).append(extra_rel)
1027
- used = set()
1028
- groups: List[List[Tuple[str, Path]]] = []
1029
- for rel, path in results:
1030
- group = [(rel, path)]
1031
- document = _load_json_if_complete(path) or {}
1032
- for source in _attachment_sources(document):
1033
- name = Path(source).name
1034
- for extra_rel in extras_by_name.get(name) or []:
1035
- if extra_rel in used:
1036
- continue
1037
- group.append(extras[extra_rel])
1038
- used.add(extra_rel)
1039
- break
1040
- groups.append(group)
1041
-
1042
- loose = [extra for rel, extra in extras.items() if rel not in used]
926
+ """Режет готовые файлы на пачки по числу и размеру, без разбора содержимого."""
1043
927
  chunks: List[List[Tuple[str, Path]]] = []
1044
928
  current: List[Tuple[str, Path]] = []
1045
929
  current_bytes = 0
@@ -1051,19 +935,16 @@ def _chunk_watch_selection(
1051
935
  current = []
1052
936
  current_bytes = 0
1053
937
 
1054
- for group in groups:
1055
- group_bytes = _selection_bytes(group)
1056
- if current and (
1057
- len(current) + len(group) > max_count
1058
- or current_bytes + group_bytes > max_bytes
1059
- ):
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):
1060
945
  flush()
1061
- if loose and not current:
1062
- current.extend(loose)
1063
- current_bytes += _selection_bytes(loose)
1064
- loose = []
1065
- current.extend(group)
1066
- current_bytes += group_bytes
946
+ current.append(item)
947
+ current_bytes += size
1067
948
  if len(current) >= max_count or current_bytes >= max_bytes:
1068
949
  flush()
1069
950
  flush()
@@ -1154,6 +1035,64 @@ def _fetch_task(host: str, project_id: str, task_id: str, token: str) -> Dict[st
1154
1035
  return resp.json()
1155
1036
 
1156
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
+
1157
1096
  @cli.command(
1158
1097
  "watch",
1159
1098
  context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
@@ -1166,7 +1105,7 @@ def _fetch_task(host: str, project_id: str, task_id: str, token: str) -> Dict[st
1166
1105
  )
1167
1106
  @click.option("--interval", default=DEFAULT_WATCH_INTERVAL_SEC, show_default=True, type=float, help="Как часто смотреть каталог (сек).")
1168
1107
  @click.option("--settle", default=DEFAULT_SETTLE_SEC, show_default=True, type=float, help="Сколько секунд размер файла не должен меняться, прежде чем его забрать.")
1169
- @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="Не используется: файл уходит, как только перестал меняться.")
1170
1109
  @click.option("--batch-window", default=DEFAULT_WATCH_BATCH_WINDOW_SEC, show_default=True, type=float, help="Сколько секунд копить готовые файлы после последнего нового, прежде чем отправить пачку.")
1171
1110
  @click.option("--batch-max-wait", default=DEFAULT_WATCH_BATCH_MAX_WAIT_SEC, show_default=True, type=float, help="Дольше этого готовые файлы не держатся, даже если новые всё ещё появляются. 0 — ждать только тишины.")
1172
1111
  @click.option("--idle-timeout", default=0, show_default=True, type=int, help="Выйти, если столько секунд нет новых файлов и загрузок. 0 — не выходить по тишине.")
@@ -1270,6 +1209,21 @@ def watch(
1270
1209
  click.echo(f"Также выйду после {idle_timeout} с тишины.")
1271
1210
 
1272
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)
1273
1227
  try:
1274
1228
  if command:
1275
1229
  child["proc"] = subprocess.Popen(
@@ -1393,6 +1347,11 @@ def watch(
1393
1347
  f"создано {status.get('results_created', 0)}, "
1394
1348
  f"обработано {status.get('results_processed', 0)}"
1395
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
1396
1355
  if state != "completed":
1397
1356
  had_failure = True
1398
1357
  for err in (status.get("errors") or [])[:3]:
@@ -1402,6 +1361,26 @@ def watch(
1402
1361
  click.echo("Тест-ран не создан, новые пачки не отправляю.", err=True)
1403
1362
  inflight = still_running
1404
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
+
1405
1384
  if run_create_failed and not inflight:
1406
1385
  click.echo("watch остановлен: первая загрузка не создала тест-ран.", err=True)
1407
1386
  watch_code = 1
@@ -1426,6 +1405,25 @@ def watch(
1426
1405
  time.sleep(interval)
1427
1406
  finally:
1428
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)
1429
1427
 
1430
1428
  test_status = 0
1431
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.0rc2
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.0rc2` — предварительная версия для SaveTest 1.5.0: `pip install savetest-cli==0.3.0rc2`. Обычный `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.0rc2"