savetest-cli 0.3.0rc3__tar.gz → 0.3.0rc4__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.0rc3
3
+ Version: 0.3.0rc4
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
@@ -196,6 +196,8 @@ savetest upload_files ./allure-results/*-result.json ./allure-results/executor.j
196
196
 
197
197
  Первая пачка создаёт тест-ран, если не передан `--test-run-id`. Следующие файлы пишутся в тот же ран: пока ран не создан, вторая пачка ждёт. Код возврата `1`, если хотя бы один импорт не завершился как `completed`.
198
198
 
199
+ Пачка уходит одним запросом, статусы задач в очереди тоже читаются одним запросом. Если сервер ещё старый и отвечает 404 или 405, до конца этого запуска watch сам возвращается к прежней схеме: три запроса на пачку и опрос задач по одной, с головы очереди до первой незавершённой. Уже выпущенные версии утилиты с новым сервером работают как раньше.
200
+
199
201
  Вложение уезжает вместе с `*-result.json`, который на него ссылается. Результат подождёт вложение до `--attachment-wait` секунд. Пачка без файла результата не отправляется.
200
202
 
201
203
  Привязки к фреймворку нет. После `--` watch запускает любую команду как есть и не разбирает её аргументы: pytest, JUnit, Maven, Gradle, npm или свой скрипт. Ему всё равно, кто пишет файлы. Нужны только `*-result.json` в каталоге, который указан первым аргументом. `pytest` в примерах ниже — один из вариантов, не требование.
@@ -220,7 +222,7 @@ savetest watch ./allure-results \
220
222
  - Если тесты и загрузка — разные джобы, отдайте каталог `allure-results` артефактом тоже с `when: always`.
221
223
  - `--wait` оставляйте включённым: джоба должна завершиться только когда импорт дошёл до конца.
222
224
  - Вложения включайте только если они реально нужны. По умолчанию CLI их не шлёт.
223
- - `0.3.0rc3` — предварительная версия для SaveTest 1.5.0: `pip install savetest-cli==0.3.0rc3`. Обычный `pip install savetest-cli` остаётся на последней стабильной версии.
225
+ - `0.3.0rc4` — предварительная версия для SaveTest 1.5.0: `pip install savetest-cli==0.3.0rc4`. Обычный `pip install savetest-cli` остаётся на последней стабильной версии.
224
226
 
225
227
  ### Результаты по мере прогона
226
228
 
@@ -1,13 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: savetest-cli
3
- Version: 0.3.0rc3
4
- Summary: CLI for SaveTest: upload Allure results as a ZIP, as raw files, or by watching a directory
5
- License-Expression: 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
1
  <p align="center">
12
2
  <img src="savetest_cli/pwa-512x512.png" alt="SaveTest" width="128">
13
3
  </p>
@@ -196,6 +186,8 @@ savetest upload_files ./allure-results/*-result.json ./allure-results/executor.j
196
186
 
197
187
  Первая пачка создаёт тест-ран, если не передан `--test-run-id`. Следующие файлы пишутся в тот же ран: пока ран не создан, вторая пачка ждёт. Код возврата `1`, если хотя бы один импорт не завершился как `completed`.
198
188
 
189
+ Пачка уходит одним запросом, статусы задач в очереди тоже читаются одним запросом. Если сервер ещё старый и отвечает 404 или 405, до конца этого запуска watch сам возвращается к прежней схеме: три запроса на пачку и опрос задач по одной, с головы очереди до первой незавершённой. Уже выпущенные версии утилиты с новым сервером работают как раньше.
190
+
199
191
  Вложение уезжает вместе с `*-result.json`, который на него ссылается. Результат подождёт вложение до `--attachment-wait` секунд. Пачка без файла результата не отправляется.
200
192
 
201
193
  Привязки к фреймворку нет. После `--` watch запускает любую команду как есть и не разбирает её аргументы: pytest, JUnit, Maven, Gradle, npm или свой скрипт. Ему всё равно, кто пишет файлы. Нужны только `*-result.json` в каталоге, который указан первым аргументом. `pytest` в примерах ниже — один из вариантов, не требование.
@@ -220,7 +212,7 @@ savetest watch ./allure-results \
220
212
  - Если тесты и загрузка — разные джобы, отдайте каталог `allure-results` артефактом тоже с `when: always`.
221
213
  - `--wait` оставляйте включённым: джоба должна завершиться только когда импорт дошёл до конца.
222
214
  - Вложения включайте только если они реально нужны. По умолчанию CLI их не шлёт.
223
- - `0.3.0rc3` — предварительная версия для SaveTest 1.5.0: `pip install savetest-cli==0.3.0rc3`. Обычный `pip install savetest-cli` остаётся на последней стабильной версии.
215
+ - `0.3.0rc4` — предварительная версия для SaveTest 1.5.0: `pip install savetest-cli==0.3.0rc4`. Обычный `pip install savetest-cli` остаётся на последней стабильной версии.
224
216
 
225
217
  ### Результаты по мере прогона
226
218
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "savetest-cli"
7
- version = "0.3.0rc3"
7
+ version = "0.3.0rc4"
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.0rc4"
@@ -537,17 +537,24 @@ def _is_result_file(path: Path) -> bool:
537
537
  return name.endswith("-result.json") or (name.startswith("test-result-") and name.endswith(".json"))
538
538
 
539
539
 
540
+ def _raw_file_name_reason(name: str, *, include_attachments: bool) -> Optional[str]:
541
+ """Причина пропустить файл по одному имени, без stat."""
542
+ if name.startswith(".") or name.lower() in _JUNK_NAMES or name == "__MACOSX":
543
+ return "служебный файл"
544
+ if name.lower().endswith(".zip"):
545
+ return "ZIP загружается командой upload"
546
+ if not include_attachments and _is_attachment(name):
547
+ return "вложение"
548
+ return None
549
+
550
+
540
551
  def _skip_raw_file(path: Path, *, include_attachments: bool) -> Optional[str]:
541
552
  """None — файл берём. Иначе короткая причина пропуска."""
542
553
  if not path.is_file():
543
554
  return "не файл"
544
- if "__MACOSX" in path.parts or path.name.startswith(".") or path.name.lower() in _JUNK_NAMES:
555
+ if "__MACOSX" in path.parts:
545
556
  return "служебный файл"
546
- if path.suffix.lower() == ".zip":
547
- return "ZIP загружается командой upload"
548
- if not include_attachments and _is_attachment(path.name):
549
- return "вложение"
550
- return None
557
+ return _raw_file_name_reason(path.name, include_attachments=include_attachments)
551
558
 
552
559
 
553
560
  def _collect_directory(root: Path, include_attachments: bool) -> list:
@@ -835,18 +842,50 @@ def _load_json_if_complete(path: Path) -> Optional[Any]:
835
842
  return None
836
843
 
837
844
 
838
- def _snapshot_raw_files(root: Path, include_attachments: bool) -> Dict[str, Tuple[Path, int, int]]:
845
+ def _snapshot_raw_files(
846
+ root: Path,
847
+ include_attachments: bool,
848
+ uploaded: Optional[set] = None,
849
+ ) -> Dict[str, Tuple[Path, int, int]]:
850
+ """Файлы каталога, которые ещё не отправлены.
851
+
852
+ Уже отправленные (rel in uploaded) не stat'ятся и в снимок не попадают.
853
+ Имя отсекается до stat. Ключ — относительный posix-путь.
854
+ """
839
855
  found: Dict[str, Tuple[Path, int, int]] = {}
840
- if not root.is_dir():
841
- return found
842
- for file_path in root.rglob("*"):
843
- if _skip_raw_file(file_path, include_attachments=include_attachments):
844
- continue
856
+ done = uploaded or ()
857
+
858
+ def walk(directory: str, prefix: str) -> None:
845
859
  try:
846
- stat = file_path.stat()
860
+ iterator = os.scandir(directory)
847
861
  except OSError:
848
- continue
849
- found[file_path.relative_to(root).as_posix()] = (file_path, stat.st_size, stat.st_mtime_ns)
862
+ return
863
+ with iterator:
864
+ for entry in iterator:
865
+ name = entry.name
866
+ rel = f"{prefix}/{name}" if prefix else name
867
+ if rel in done:
868
+ continue
869
+ try:
870
+ if entry.is_dir(follow_symlinks=False):
871
+ if name != "__MACOSX":
872
+ walk(entry.path, rel)
873
+ continue
874
+ except OSError:
875
+ continue
876
+ if _raw_file_name_reason(name, include_attachments=include_attachments):
877
+ continue
878
+ if "__MACOSX" in rel.split("/"):
879
+ continue
880
+ try:
881
+ if not entry.is_file(follow_symlinks=False):
882
+ continue
883
+ file_stat = entry.stat(follow_symlinks=False)
884
+ except OSError:
885
+ continue
886
+ found[rel] = (Path(entry.path), file_stat.st_size, file_stat.st_mtime_ns)
887
+
888
+ walk(os.fspath(root), "")
850
889
  return found
851
890
 
852
891
 
@@ -951,12 +990,23 @@ def _chunk_watch_selection(
951
990
  return chunks
952
991
 
953
992
 
954
- def _enqueue_watch_batch(
955
- selected: List[Tuple[str, Path]],
993
+ class _WatchApi:
994
+ """Какие быстрые ручки ещё пробуем в этом запуске watch.
995
+
996
+ 404/405 значит, что сервер старый: до конца запуска остаётся прежний путь.
997
+ """
998
+
999
+ def __init__(self) -> None:
1000
+ self.files_batch = True
1001
+ self.tasks_batch = True
1002
+
1003
+
1004
+ _TASK_STATUS_BATCH_LIMIT = 500
1005
+ _MISSING_ROUTE = frozenset({404, 405})
1006
+
1007
+
1008
+ def _watch_form_data(
956
1009
  *,
957
- host: str,
958
- token: str,
959
- project_id: str,
960
1010
  test_run_id: Optional[str],
961
1011
  test_run_name: Optional[str],
962
1012
  description: Optional[str],
@@ -969,18 +1019,17 @@ def _enqueue_watch_batch(
969
1019
  configuration_name: Optional[str],
970
1020
  branch: Optional[str],
971
1021
  include_attachments: bool,
972
- ) -> str:
973
- headers = {"Authorization": f"Bearer {token}"}
974
- init_data: Dict[str, str] = {
1022
+ ) -> Dict[str, str]:
1023
+ data: Dict[str, str] = {
975
1024
  "include_attachments": "true" if include_attachments else "false",
976
1025
  }
977
1026
  if branch:
978
- init_data["git_branch"] = branch
1027
+ data["git_branch"] = branch
979
1028
  if test_run_id:
980
- init_data["test_run_id"] = str(test_run_id)
1029
+ data["test_run_id"] = str(test_run_id)
981
1030
  else:
982
1031
  _append_new_test_run_fields(
983
- init_data,
1032
+ data,
984
1033
  test_run_name=test_run_name,
985
1034
  description=description,
986
1035
  version=version,
@@ -991,7 +1040,58 @@ def _enqueue_watch_batch(
991
1040
  configuration_id=configuration_id,
992
1041
  configuration_name=configuration_name,
993
1042
  )
1043
+ return data
1044
+
1045
+
1046
+ def _require_task_id(payload: Dict[str, Any]) -> str:
1047
+ task_id = payload.get("task_id")
1048
+ if not task_id:
1049
+ click.echo("Файлы приняты, но task_id не получен.", err=True)
1050
+ sys.exit(1)
1051
+ return str(task_id)
994
1052
 
1053
+
1054
+ def _try_watch_files_batch(
1055
+ selected: List[Tuple[str, Path]],
1056
+ *,
1057
+ host: str,
1058
+ project_id: str,
1059
+ headers: Dict[str, str],
1060
+ init_data: Dict[str, str],
1061
+ api: _WatchApi,
1062
+ ) -> Optional[str]:
1063
+ """task_id новой ручки. None — сервер её не знает, флаг уже снят."""
1064
+ if not api.files_batch:
1065
+ return None
1066
+ url = f"{host}/api/v1/projects/{project_id}/allure-results/files/batch"
1067
+ handles = []
1068
+ multipart = []
1069
+ try:
1070
+ for rel, path in selected:
1071
+ handle = open(path, "rb")
1072
+ handles.append(handle)
1073
+ multipart.append(("files", (path.name, handle, "application/octet-stream")))
1074
+ multipart.append(("relative_paths", (None, rel)))
1075
+ resp = requests.post(url, headers=headers, data=init_data, files=multipart, timeout=180)
1076
+ finally:
1077
+ for handle in handles:
1078
+ handle.close()
1079
+ if resp.status_code in _MISSING_ROUTE:
1080
+ api.files_batch = False
1081
+ click.echo("Сервер без пакетной загрузки, отправляю пачку прежним способом.")
1082
+ return None
1083
+ _check_response(resp, "watch batch")
1084
+ return _require_task_id(resp.json())
1085
+
1086
+
1087
+ def _enqueue_watch_batch_steps(
1088
+ selected: List[Tuple[str, Path]],
1089
+ *,
1090
+ host: str,
1091
+ project_id: str,
1092
+ headers: Dict[str, str],
1093
+ init_data: Dict[str, str],
1094
+ ) -> str:
995
1095
  resp = requests.post(
996
1096
  f"{host}/api/v1/projects/{project_id}/allure-results/files/init",
997
1097
  headers=headers,
@@ -1018,11 +1118,64 @@ def _enqueue_watch_batch(
1018
1118
 
1019
1119
  resp = requests.post(f"{url}/complete", headers=headers, timeout=60)
1020
1120
  _check_response(resp, "watch complete")
1021
- task_id = resp.json().get("task_id")
1022
- if not task_id:
1023
- click.echo("Файлы приняты, но task_id не получен.", err=True)
1024
- sys.exit(1)
1025
- return str(task_id)
1121
+ return _require_task_id(resp.json())
1122
+
1123
+
1124
+ def _enqueue_watch_batch(
1125
+ selected: List[Tuple[str, Path]],
1126
+ *,
1127
+ host: str,
1128
+ token: str,
1129
+ project_id: str,
1130
+ test_run_id: Optional[str],
1131
+ test_run_name: Optional[str],
1132
+ description: Optional[str],
1133
+ version: Optional[str],
1134
+ revision: Optional[str],
1135
+ test_object: Optional[str],
1136
+ epic_id: Optional[str],
1137
+ epic_name: Optional[str],
1138
+ configuration_id: Optional[str],
1139
+ configuration_name: Optional[str],
1140
+ branch: Optional[str],
1141
+ include_attachments: bool,
1142
+ api: Optional[_WatchApi] = None,
1143
+ ) -> str:
1144
+ if api is None:
1145
+ api = _WatchApi()
1146
+ headers = {"Authorization": f"Bearer {token}"}
1147
+ init_data = _watch_form_data(
1148
+ test_run_id=test_run_id,
1149
+ test_run_name=test_run_name,
1150
+ description=description,
1151
+ version=version,
1152
+ revision=revision,
1153
+ test_object=test_object,
1154
+ epic_id=epic_id,
1155
+ epic_name=epic_name,
1156
+ configuration_id=configuration_id,
1157
+ configuration_name=configuration_name,
1158
+ branch=branch,
1159
+ include_attachments=include_attachments,
1160
+ )
1161
+ if api.files_batch:
1162
+ task_id = _try_watch_files_batch(
1163
+ selected,
1164
+ host=host,
1165
+ project_id=project_id,
1166
+ headers=headers,
1167
+ init_data=init_data,
1168
+ api=api,
1169
+ )
1170
+ if task_id:
1171
+ return task_id
1172
+ return _enqueue_watch_batch_steps(
1173
+ selected,
1174
+ host=host,
1175
+ project_id=project_id,
1176
+ headers=headers,
1177
+ init_data=init_data,
1178
+ )
1026
1179
 
1027
1180
 
1028
1181
  def _fetch_task(host: str, project_id: str, task_id: str, token: str) -> Dict[str, Any]:
@@ -1035,6 +1188,83 @@ def _fetch_task(host: str, project_id: str, task_id: str, token: str) -> Dict[st
1035
1188
  return resp.json()
1036
1189
 
1037
1190
 
1191
+ def _task_status_items(body: Any) -> List[Dict[str, Any]]:
1192
+ if isinstance(body, list):
1193
+ items = body
1194
+ elif isinstance(body, dict) and isinstance(body.get("tasks"), list):
1195
+ items = body["tasks"]
1196
+ else:
1197
+ items = []
1198
+ return [item for item in items if isinstance(item, dict)]
1199
+
1200
+
1201
+ def _fetch_task_status_map(
1202
+ task_ids: List[str],
1203
+ *,
1204
+ host: str,
1205
+ project_id: str,
1206
+ token: str,
1207
+ api: _WatchApi,
1208
+ ) -> Optional[Dict[str, Dict[str, Any]]]:
1209
+ """Карта task_id -> статус. None — сервер без пакетной ручки."""
1210
+ if not api.tasks_batch or not task_ids:
1211
+ return None
1212
+ found: Dict[str, Dict[str, Any]] = {}
1213
+ headers = {"Authorization": f"Bearer {token}"}
1214
+ url = f"{host}/api/v1/projects/{project_id}/allure-results/tasks/status"
1215
+ for offset in range(0, len(task_ids), _TASK_STATUS_BATCH_LIMIT):
1216
+ chunk = task_ids[offset:offset + _TASK_STATUS_BATCH_LIMIT]
1217
+ resp = requests.post(url, headers=headers, json={"task_ids": chunk}, timeout=60)
1218
+ if resp.status_code in _MISSING_ROUTE:
1219
+ api.tasks_batch = False
1220
+ click.echo("Сервер без пакетного статуса задач, опрашиваю по одной.")
1221
+ return None
1222
+ _check_response(resp, "watch tasks")
1223
+ for item in _task_status_items(resp.json()):
1224
+ task_id = item.get("task_id")
1225
+ if task_id:
1226
+ found[str(task_id)] = item
1227
+ return found
1228
+
1229
+
1230
+ def _poll_watch_tasks(
1231
+ inflight: List[str],
1232
+ *,
1233
+ host: str,
1234
+ project_id: str,
1235
+ token: str,
1236
+ api: _WatchApi,
1237
+ ) -> List[Tuple[str, Dict[str, Any]]]:
1238
+ """Статусы inflight.
1239
+
1240
+ Новая ручка читает все сразу. Старый сервер — с головы до первой незавершённой.
1241
+ """
1242
+ if not inflight:
1243
+ return []
1244
+ batched = _fetch_task_status_map(
1245
+ inflight,
1246
+ host=host,
1247
+ project_id=project_id,
1248
+ token=token,
1249
+ api=api,
1250
+ )
1251
+ if batched is not None:
1252
+ pairs: List[Tuple[str, Dict[str, Any]]] = []
1253
+ for task_id in inflight:
1254
+ status = batched.get(task_id)
1255
+ if status is None:
1256
+ status = _fetch_task(host, project_id, task_id, token)
1257
+ pairs.append((task_id, status))
1258
+ return pairs
1259
+ pairs = []
1260
+ for task_id in inflight:
1261
+ status = _fetch_task(host, project_id, task_id, token)
1262
+ pairs.append((task_id, status))
1263
+ if status.get("status", "") not in TERMINAL_STATUSES:
1264
+ break
1265
+ return pairs
1266
+
1267
+
1038
1268
  def _watch_session_url(host: str, project_id: str, session_id: Optional[str] = None, action: str = "") -> str:
1039
1269
  url = f"{host}/api/v1/projects/{project_id}/allure-results/watch/sessions"
1040
1270
  if session_id:
@@ -1152,7 +1382,9 @@ def watch(
1152
1382
  Готовые файлы не уходят по одному. Если они появляются подряд, watch держит их
1153
1383
  --batch-window секунд после последнего нового и отправляет вместе. Дольше
1154
1384
  --batch-max-wait секунд пачка не копится. Предел одной отправки — 200 файлов
1155
- или 32 МБ.
1385
+ или 32 МБ. Если сервер умеет, пачка и статусы задач уходят одним запросом.
1386
+ Ответ 404 или 405 — до конца этого запуска прежний путь: три запроса на пачку
1387
+ и опрос задач с головы очереди до первой незавершённой.
1156
1388
  """
1157
1389
  command = [arg for arg in ctx.args if arg != "--"]
1158
1390
  host = host.rstrip("/")
@@ -1166,6 +1398,7 @@ def watch(
1166
1398
  had_failure = False
1167
1399
  run_create_failed = False
1168
1400
  sent_batches = 0
1401
+ watch_api = _WatchApi()
1169
1402
  drain_until = 0.0
1170
1403
  announced_flush = False
1171
1404
  hold_opened = 0.0
@@ -1240,7 +1473,7 @@ def watch(
1240
1473
  timed_out = bool(timeout) and (now - started) >= timeout
1241
1474
  flushing = stop["requested"] or timed_out
1242
1475
 
1243
- snapshot = _snapshot_raw_files(root, include_attachments) if root.is_dir() else {}
1476
+ snapshot = _snapshot_raw_files(root, include_attachments, uploaded)
1244
1477
  if flushing and not announced_flush:
1245
1478
  pending = sum(1 for rel in snapshot if rel not in uploaded)
1246
1479
  click.echo(f"После остановки осталось файлов: {pending}.")
@@ -1319,6 +1552,7 @@ def watch(
1319
1552
  configuration_name=configuration_name,
1320
1553
  branch=branch,
1321
1554
  include_attachments=include_attachments,
1555
+ api=watch_api,
1322
1556
  )
1323
1557
  for rel, _path in chunk:
1324
1558
  uploaded.add(rel)
@@ -1329,9 +1563,15 @@ def watch(
1329
1563
  hold_opened = 0.0
1330
1564
  hold_key = None
1331
1565
 
1566
+ pairs = _poll_watch_tasks(
1567
+ inflight,
1568
+ host=host,
1569
+ project_id=project_id,
1570
+ token=token,
1571
+ api=watch_api,
1572
+ )
1332
1573
  still_running: List[str] = []
1333
- for task_id in inflight:
1334
- status = _fetch_task(host, project_id, task_id, token)
1574
+ for task_id, status in pairs:
1335
1575
  state = status.get("status", "")
1336
1576
  if state not in TERMINAL_STATUSES:
1337
1577
  still_running.append(task_id)
@@ -1359,6 +1599,8 @@ def watch(
1359
1599
  if not known_run_id:
1360
1600
  run_create_failed = True
1361
1601
  click.echo("Тест-ран не создан, новые пачки не отправляю.", err=True)
1602
+ if len(pairs) < len(inflight):
1603
+ still_running.extend(inflight[len(pairs):])
1362
1604
  inflight = still_running
1363
1605
 
1364
1606
  if session_id and (
@@ -1434,3 +1676,7 @@ def watch(
1434
1676
  test_status = child["proc"].wait()
1435
1677
  if test_status or watch_code:
1436
1678
  sys.exit(test_status or watch_code)
1679
+
1680
+
1681
+ if __name__ == "__main__":
1682
+ cli()
@@ -1,3 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: savetest-cli
3
+ Version: 0.3.0rc4
4
+ Summary: CLI for SaveTest: upload Allure results as a ZIP, as raw files, or by watching a directory
5
+ License-Expression: 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
+
1
11
  <p align="center">
2
12
  <img src="savetest_cli/pwa-512x512.png" alt="SaveTest" width="128">
3
13
  </p>
@@ -186,6 +196,8 @@ savetest upload_files ./allure-results/*-result.json ./allure-results/executor.j
186
196
 
187
197
  Первая пачка создаёт тест-ран, если не передан `--test-run-id`. Следующие файлы пишутся в тот же ран: пока ран не создан, вторая пачка ждёт. Код возврата `1`, если хотя бы один импорт не завершился как `completed`.
188
198
 
199
+ Пачка уходит одним запросом, статусы задач в очереди тоже читаются одним запросом. Если сервер ещё старый и отвечает 404 или 405, до конца этого запуска watch сам возвращается к прежней схеме: три запроса на пачку и опрос задач по одной, с головы очереди до первой незавершённой. Уже выпущенные версии утилиты с новым сервером работают как раньше.
200
+
189
201
  Вложение уезжает вместе с `*-result.json`, который на него ссылается. Результат подождёт вложение до `--attachment-wait` секунд. Пачка без файла результата не отправляется.
190
202
 
191
203
  Привязки к фреймворку нет. После `--` watch запускает любую команду как есть и не разбирает её аргументы: pytest, JUnit, Maven, Gradle, npm или свой скрипт. Ему всё равно, кто пишет файлы. Нужны только `*-result.json` в каталоге, который указан первым аргументом. `pytest` в примерах ниже — один из вариантов, не требование.
@@ -210,7 +222,7 @@ savetest watch ./allure-results \
210
222
  - Если тесты и загрузка — разные джобы, отдайте каталог `allure-results` артефактом тоже с `when: always`.
211
223
  - `--wait` оставляйте включённым: джоба должна завершиться только когда импорт дошёл до конца.
212
224
  - Вложения включайте только если они реально нужны. По умолчанию CLI их не шлёт.
213
- - `0.3.0rc3` — предварительная версия для SaveTest 1.5.0: `pip install savetest-cli==0.3.0rc3`. Обычный `pip install savetest-cli` остаётся на последней стабильной версии.
225
+ - `0.3.0rc4` — предварительная версия для SaveTest 1.5.0: `pip install savetest-cli==0.3.0rc4`. Обычный `pip install savetest-cli` остаётся на последней стабильной версии.
214
226
 
215
227
  ### Результаты по мере прогона
216
228
 
@@ -9,4 +9,5 @@ savetest_cli.egg-info/SOURCES.txt
9
9
  savetest_cli.egg-info/dependency_links.txt
10
10
  savetest_cli.egg-info/entry_points.txt
11
11
  savetest_cli.egg-info/requires.txt
12
- savetest_cli.egg-info/top_level.txt
12
+ savetest_cli.egg-info/top_level.txt
13
+ tests/test_watch_fastpath.py
@@ -0,0 +1,219 @@
1
+ """Снимок watch и фолбэк на старые ручки."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from unittest.mock import patch
6
+
7
+ import pytest
8
+
9
+ from savetest_cli.cli import (
10
+ _WatchApi,
11
+ _enqueue_watch_batch,
12
+ _poll_watch_tasks,
13
+ _snapshot_raw_files,
14
+ )
15
+
16
+
17
+ class _Resp:
18
+ def __init__(self, status_code, payload):
19
+ self.status_code = status_code
20
+ self._payload = payload
21
+ self.ok = 200 <= status_code < 300
22
+ self.text = payload if isinstance(payload, str) else ""
23
+
24
+ def json(self):
25
+ if isinstance(self._payload, str):
26
+ raise ValueError("not json")
27
+ return self._payload
28
+
29
+
30
+ def _send(selected, api):
31
+ return _enqueue_watch_batch(
32
+ selected,
33
+ host="https://example.test",
34
+ token="tok",
35
+ project_id="7",
36
+ test_run_id=None,
37
+ test_run_name="Прогон",
38
+ description=None,
39
+ version=None,
40
+ revision=None,
41
+ test_object=None,
42
+ epic_id=None,
43
+ epic_name=None,
44
+ configuration_id=None,
45
+ configuration_name=None,
46
+ branch="main",
47
+ include_attachments=False,
48
+ api=api,
49
+ )
50
+
51
+
52
+ def _kind(url: str) -> str:
53
+ if url.endswith("/files/batch"):
54
+ return "batch"
55
+ if url.endswith("/files/init"):
56
+ return "init"
57
+ if url.endswith("/complete"):
58
+ return "complete"
59
+ if "/allure-results/files/" in url:
60
+ return "upload"
61
+ return url
62
+
63
+
64
+ def test_snapshot_does_not_stat_uploaded_files(tmp_path: Path):
65
+ (tmp_path / "sent-result.json").write_text("{}", encoding="utf-8")
66
+ (tmp_path / "new-result.json").write_text('{"status": "passed"}', encoding="utf-8")
67
+ nested = tmp_path / "sub" / "deep"
68
+ nested.mkdir(parents=True)
69
+ (nested / "nested-result.json").write_text("{}", encoding="utf-8")
70
+ (tmp_path / "sub" / "old-result.json").write_text("{}", encoding="utf-8")
71
+ (tmp_path / ".DS_Store").write_text("x", encoding="utf-8")
72
+ (tmp_path / "shot.png").write_text("x", encoding="utf-8")
73
+ (tmp_path / "pack.zip").write_text("x", encoding="utf-8")
74
+ macos = tmp_path / "__MACOSX"
75
+ macos.mkdir()
76
+ (macos / "junk-result.json").write_text("{}", encoding="utf-8")
77
+
78
+ uploaded = {"sent-result.json", "sub/old-result.json"}
79
+ uploaded_names = {"sent-result.json", "old-result.json"}
80
+ skipped_names = uploaded_names | {".DS_Store", "shot.png", "pack.zip", "junk-result.json"}
81
+ stat_names = []
82
+ real_dir_stat = os.DirEntry.stat
83
+ real_os_stat = os.stat
84
+
85
+ def dir_stat(self, *args, **kwargs):
86
+ stat_names.append(self.name)
87
+ if self.name in uploaded_names:
88
+ raise AssertionError(f"DirEntry.stat для уже отправленного {self.name}")
89
+ return real_dir_stat(self, *args, **kwargs)
90
+
91
+ def os_stat(path, *args, **kwargs):
92
+ base = os.path.basename(os.fspath(path))
93
+ if base in uploaded_names:
94
+ raise AssertionError(f"os.stat для уже отправленного {base}")
95
+ return real_os_stat(path, *args, **kwargs)
96
+
97
+ with patch.object(os.DirEntry, "stat", dir_stat), patch("os.stat", os_stat):
98
+ found = _snapshot_raw_files(tmp_path, False, uploaded)
99
+
100
+ assert set(found) == {"new-result.json", "sub/deep/nested-result.json"}
101
+ assert "new-result.json" in stat_names
102
+ assert "nested-result.json" in stat_names
103
+ assert not (set(stat_names) & skipped_names)
104
+ path, size, mtime = found["sub/deep/nested-result.json"]
105
+ assert path.name == "nested-result.json"
106
+ assert size > 0
107
+ assert mtime > 0
108
+ assert _snapshot_raw_files(tmp_path / "missing", False, set()) == {}
109
+
110
+
111
+ def test_files_batch_404_falls_back_once(tmp_path: Path):
112
+ result = tmp_path / "a-result.json"
113
+ result.write_text("{}", encoding="utf-8")
114
+ selected = [("a-result.json", result)]
115
+ api = _WatchApi()
116
+ calls = []
117
+
118
+ def fake_post(url, **kwargs):
119
+ calls.append(_kind(url))
120
+ if url.endswith("/files/batch"):
121
+ assert kwargs["data"]["include_attachments"] == "false"
122
+ assert kwargs["data"]["git_branch"] == "main"
123
+ assert kwargs["data"]["test_run_name"] == "Прогон"
124
+ return _Resp(404, "missing")
125
+ if url.endswith("/files/init"):
126
+ return _Resp(200, {"upload_id": "up1"})
127
+ if url.endswith("/complete"):
128
+ return _Resp(200, {"task_id": "task-1", "status": "pending"})
129
+ if "/allure-results/files/" in url:
130
+ return _Resp(200, {"ok": True})
131
+ raise AssertionError(url)
132
+
133
+ with patch("savetest_cli.cli.requests.post", side_effect=fake_post):
134
+ assert _send(selected, api) == "task-1"
135
+ assert _send(selected, api) == "task-1"
136
+
137
+ assert calls == [
138
+ "batch", "init", "upload", "complete",
139
+ "init", "upload", "complete",
140
+ ]
141
+ assert api.files_batch is False
142
+
143
+
144
+ def test_task_status_404_polls_until_first_open():
145
+ api = _WatchApi()
146
+ calls = []
147
+ states = {"a": "completed", "b": "processing", "c": "completed"}
148
+
149
+ def fake_post(url, **kwargs):
150
+ calls.append(("POST", url))
151
+ assert kwargs["json"] == {"task_ids": ["a", "b", "c"]}
152
+ return _Resp(404, "missing")
153
+
154
+ def fake_get(url, **kwargs):
155
+ task_id = url.rstrip("/").split("/")[-1]
156
+ calls.append(("GET", task_id))
157
+ return _Resp(200, {"task_id": task_id, "status": states[task_id]})
158
+
159
+ with patch("savetest_cli.cli.requests.post", side_effect=fake_post), patch(
160
+ "savetest_cli.cli.requests.get", side_effect=fake_get
161
+ ):
162
+ first = _poll_watch_tasks(
163
+ ["a", "b", "c"],
164
+ host="https://example.test",
165
+ project_id="7",
166
+ token="tok",
167
+ api=api,
168
+ )
169
+ second = _poll_watch_tasks(
170
+ ["a", "b", "c"],
171
+ host="https://example.test",
172
+ project_id="7",
173
+ token="tok",
174
+ api=api,
175
+ )
176
+
177
+ assert [task_id for task_id, _status in first] == ["a", "b"]
178
+ assert first[-1][1]["status"] == "processing"
179
+ assert [task_id for task_id, _status in second] == ["a", "b"]
180
+ assert calls == [
181
+ ("POST", "https://example.test/api/v1/projects/7/allure-results/tasks/status"),
182
+ ("GET", "a"),
183
+ ("GET", "b"),
184
+ ("GET", "a"),
185
+ ("GET", "b"),
186
+ ]
187
+ assert api.tasks_batch is False
188
+
189
+
190
+ def test_task_status_batch_reads_all_without_get(monkeypatch: pytest.MonkeyPatch):
191
+ api = _WatchApi()
192
+ posts = []
193
+ monkeypatch.setattr("savetest_cli.cli._TASK_STATUS_BATCH_LIMIT", 2)
194
+
195
+ def fake_post(url, **kwargs):
196
+ posts.append(kwargs["json"]["task_ids"])
197
+ payload = [
198
+ {"task_id": task_id, "status": "completed"}
199
+ for task_id in kwargs["json"]["task_ids"]
200
+ ]
201
+ return _Resp(200, payload)
202
+
203
+ def fake_get(url, **kwargs):
204
+ raise AssertionError(url)
205
+
206
+ with patch("savetest_cli.cli.requests.post", side_effect=fake_post), patch(
207
+ "savetest_cli.cli.requests.get", side_effect=fake_get
208
+ ):
209
+ pairs = _poll_watch_tasks(
210
+ ["a", "b", "c"],
211
+ host="https://example.test",
212
+ project_id="7",
213
+ token="tok",
214
+ api=api,
215
+ )
216
+
217
+ assert posts == [["a", "b"], ["c"]]
218
+ assert [task_id for task_id, _status in pairs] == ["a", "b", "c"]
219
+ assert api.tasks_batch is True
@@ -1 +0,0 @@
1
- __version__ = "0.3.0rc3"