savetest-cli 0.3.0rc2__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.0rc2
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.0rc2` — предварительная версия для SaveTest 1.5.0: `pip install savetest-cli==0.3.0rc2`. Обычный `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.0rc2
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.0rc2` — предварительная версия для SaveTest 1.5.0: `pip install savetest-cli==0.3.0rc2`. Обычный `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.0rc2"
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"
@@ -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
 
@@ -536,17 +537,24 @@ def _is_result_file(path: Path) -> bool:
536
537
  return name.endswith("-result.json") or (name.startswith("test-result-") and name.endswith(".json"))
537
538
 
538
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
+
539
551
  def _skip_raw_file(path: Path, *, include_attachments: bool) -> Optional[str]:
540
552
  """None — файл берём. Иначе короткая причина пропуска."""
541
553
  if not path.is_file():
542
554
  return "не файл"
543
- if "__MACOSX" in path.parts or path.name.startswith(".") or path.name.lower() in _JUNK_NAMES:
555
+ if "__MACOSX" in path.parts:
544
556
  return "служебный файл"
545
- if path.suffix.lower() == ".zip":
546
- return "ZIP загружается командой upload"
547
- if not include_attachments and _is_attachment(path.name):
548
- return "вложение"
549
- return None
557
+ return _raw_file_name_reason(path.name, include_attachments=include_attachments)
550
558
 
551
559
 
552
560
  def _collect_directory(root: Path, include_attachments: bool) -> list:
@@ -826,26 +834,6 @@ def upload_files(
826
834
  # watch — каталог в реальном времени
827
835
  # ---------------------------------------------------------------------------
828
836
 
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
837
  def _load_json_if_complete(path: Path) -> Optional[Any]:
850
838
  try:
851
839
  with open(path, "r", encoding="utf-8") as handle:
@@ -854,49 +842,51 @@ def _load_json_if_complete(path: Path) -> Optional[Any]:
854
842
  return None
855
843
 
856
844
 
857
- 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
+ """
858
855
  found: Dict[str, Tuple[Path, int, int]] = {}
859
- if not root.is_dir():
860
- return found
861
- for file_path in root.rglob("*"):
862
- if _skip_raw_file(file_path, include_attachments=include_attachments):
863
- continue
856
+ done = uploaded or ()
857
+
858
+ def walk(directory: str, prefix: str) -> None:
864
859
  try:
865
- stat = file_path.stat()
860
+ iterator = os.scandir(directory)
866
861
  except OSError:
867
- continue
868
- found[file_path.relative_to(root).as_posix()] = (file_path, stat.st_size, stat.st_mtime_ns)
869
- return found
870
-
871
-
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
-
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)
881
887
 
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]
888
+ walk(os.fspath(root), "")
889
+ return found
900
890
 
901
891
 
902
892
  def _pick_watch_batch(
@@ -910,11 +900,8 @@ def _pick_watch_batch(
910
900
  *,
911
901
  flushing: bool,
912
902
  ) -> List[Tuple[str, Path]]:
913
- """Файлы, которые уже дописаны и их можно отправить одной пачкой.
914
-
915
- Результат ждёт свои вложения не дольше attachment_wait_sec, чтобы не уехать
916
- раньше скриншота. Пачка без *-result.json не отправляется: сервер её не примет.
917
- """
903
+ """Файлы, которые уже дописаны. Результат и вложение уходят по отдельности."""
904
+ del include_attachments, attachment_wait_sec
918
905
  ready: Dict[str, Path] = {}
919
906
  for rel in list(stable_since):
920
907
  if rel not in snapshot:
@@ -936,50 +923,7 @@ def _pick_watch_batch(
936
923
  continue
937
924
  ready[rel] = path
938
925
 
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
926
+ return [(rel, path) for rel, path in ready.items()]
983
927
 
984
928
 
985
929
  def _watch_batch_due(
@@ -1018,28 +962,7 @@ def _chunk_watch_selection(
1018
962
  max_count: int = DEFAULT_FILE_BATCH_COUNT,
1019
963
  max_bytes: int = DEFAULT_FILE_BATCH_BYTES,
1020
964
  ) -> 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]
965
+ """Режет готовые файлы на пачки по числу и размеру, без разбора содержимого."""
1043
966
  chunks: List[List[Tuple[str, Path]]] = []
1044
967
  current: List[Tuple[str, Path]] = []
1045
968
  current_bytes = 0
@@ -1051,31 +974,39 @@ def _chunk_watch_selection(
1051
974
  current = []
1052
975
  current_bytes = 0
1053
976
 
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
- ):
977
+ for item in selected:
978
+ _rel, path = item
979
+ try:
980
+ size = path.stat().st_size
981
+ except OSError:
982
+ size = 0
983
+ if current and (len(current) >= max_count or current_bytes + size > max_bytes):
1060
984
  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
985
+ current.append(item)
986
+ current_bytes += size
1067
987
  if len(current) >= max_count or current_bytes >= max_bytes:
1068
988
  flush()
1069
989
  flush()
1070
990
  return chunks
1071
991
 
1072
992
 
1073
- def _enqueue_watch_batch(
1074
- 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(
1075
1009
  *,
1076
- host: str,
1077
- token: str,
1078
- project_id: str,
1079
1010
  test_run_id: Optional[str],
1080
1011
  test_run_name: Optional[str],
1081
1012
  description: Optional[str],
@@ -1088,18 +1019,17 @@ def _enqueue_watch_batch(
1088
1019
  configuration_name: Optional[str],
1089
1020
  branch: Optional[str],
1090
1021
  include_attachments: bool,
1091
- ) -> str:
1092
- headers = {"Authorization": f"Bearer {token}"}
1093
- init_data: Dict[str, str] = {
1022
+ ) -> Dict[str, str]:
1023
+ data: Dict[str, str] = {
1094
1024
  "include_attachments": "true" if include_attachments else "false",
1095
1025
  }
1096
1026
  if branch:
1097
- init_data["git_branch"] = branch
1027
+ data["git_branch"] = branch
1098
1028
  if test_run_id:
1099
- init_data["test_run_id"] = str(test_run_id)
1029
+ data["test_run_id"] = str(test_run_id)
1100
1030
  else:
1101
1031
  _append_new_test_run_fields(
1102
- init_data,
1032
+ data,
1103
1033
  test_run_name=test_run_name,
1104
1034
  description=description,
1105
1035
  version=version,
@@ -1110,7 +1040,58 @@ def _enqueue_watch_batch(
1110
1040
  configuration_id=configuration_id,
1111
1041
  configuration_name=configuration_name,
1112
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)
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
+
1113
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:
1114
1095
  resp = requests.post(
1115
1096
  f"{host}/api/v1/projects/{project_id}/allure-results/files/init",
1116
1097
  headers=headers,
@@ -1137,11 +1118,64 @@ def _enqueue_watch_batch(
1137
1118
 
1138
1119
  resp = requests.post(f"{url}/complete", headers=headers, timeout=60)
1139
1120
  _check_response(resp, "watch complete")
1140
- task_id = resp.json().get("task_id")
1141
- if not task_id:
1142
- click.echo("Файлы приняты, но task_id не получен.", err=True)
1143
- sys.exit(1)
1144
- 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
+ )
1145
1179
 
1146
1180
 
1147
1181
  def _fetch_task(host: str, project_id: str, task_id: str, token: str) -> Dict[str, Any]:
@@ -1154,6 +1188,141 @@ def _fetch_task(host: str, project_id: str, task_id: str, token: str) -> Dict[st
1154
1188
  return resp.json()
1155
1189
 
1156
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
+
1268
+ def _watch_session_url(host: str, project_id: str, session_id: Optional[str] = None, action: str = "") -> str:
1269
+ url = f"{host}/api/v1/projects/{project_id}/allure-results/watch/sessions"
1270
+ if session_id:
1271
+ url = f"{url}/{session_id}"
1272
+ if action:
1273
+ url = f"{url}/{action}"
1274
+ return url
1275
+
1276
+
1277
+ def _post_watch_session(
1278
+ host: str,
1279
+ project_id: str,
1280
+ token: str,
1281
+ payload: Dict[str, Any],
1282
+ session_id: Optional[str] = None,
1283
+ action: str = "",
1284
+ ) -> Optional[Dict[str, Any]]:
1285
+ """Сигнал платформе. Ошибка здесь не останавливает тесты."""
1286
+ try:
1287
+ resp = requests.post(
1288
+ _watch_session_url(host, project_id, session_id, action),
1289
+ headers={"Authorization": f"Bearer {token}"},
1290
+ json=payload,
1291
+ timeout=20,
1292
+ )
1293
+ except requests.RequestException:
1294
+ return None
1295
+ if resp.status_code >= 400:
1296
+ return None
1297
+ try:
1298
+ body = resp.json()
1299
+ except ValueError:
1300
+ return None
1301
+ return body if isinstance(body, dict) else None
1302
+
1303
+
1304
+ def _watch_session_payload(
1305
+ *,
1306
+ test_run_id: Optional[str],
1307
+ test_run_name: Optional[str],
1308
+ stats: Dict[str, int],
1309
+ status: Optional[str] = None,
1310
+ ) -> Dict[str, Any]:
1311
+ payload: Dict[str, Any] = {
1312
+ "results_processed": stats["processed"],
1313
+ "results_created": stats["created"],
1314
+ "test_cases_matched": stats["matched"],
1315
+ "skipped": stats["skipped"],
1316
+ }
1317
+ if test_run_name:
1318
+ payload["test_run_name"] = test_run_name
1319
+ if test_run_id and str(test_run_id).isdigit():
1320
+ payload["test_run_id"] = int(test_run_id)
1321
+ if status:
1322
+ payload["status"] = status
1323
+ return payload
1324
+
1325
+
1157
1326
  @cli.command(
1158
1327
  "watch",
1159
1328
  context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
@@ -1166,7 +1335,7 @@ def _fetch_task(host: str, project_id: str, task_id: str, token: str) -> Dict[st
1166
1335
  )
1167
1336
  @click.option("--interval", default=DEFAULT_WATCH_INTERVAL_SEC, show_default=True, type=float, help="Как часто смотреть каталог (сек).")
1168
1337
  @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.")
1338
+ @click.option("--attachment-wait", default=DEFAULT_ATTACHMENT_WAIT_SEC, show_default=True, type=float, help="Не используется: файл уходит, как только перестал меняться.")
1170
1339
  @click.option("--batch-window", default=DEFAULT_WATCH_BATCH_WINDOW_SEC, show_default=True, type=float, help="Сколько секунд копить готовые файлы после последнего нового, прежде чем отправить пачку.")
1171
1340
  @click.option("--batch-max-wait", default=DEFAULT_WATCH_BATCH_MAX_WAIT_SEC, show_default=True, type=float, help="Дольше этого готовые файлы не держатся, даже если новые всё ещё появляются. 0 — ждать только тишины.")
1172
1341
  @click.option("--idle-timeout", default=0, show_default=True, type=int, help="Выйти, если столько секунд нет новых файлов и загрузок. 0 — не выходить по тишине.")
@@ -1213,7 +1382,9 @@ def watch(
1213
1382
  Готовые файлы не уходят по одному. Если они появляются подряд, watch держит их
1214
1383
  --batch-window секунд после последнего нового и отправляет вместе. Дольше
1215
1384
  --batch-max-wait секунд пачка не копится. Предел одной отправки — 200 файлов
1216
- или 32 МБ.
1385
+ или 32 МБ. Если сервер умеет, пачка и статусы задач уходят одним запросом.
1386
+ Ответ 404 или 405 — до конца этого запуска прежний путь: три запроса на пачку
1387
+ и опрос задач с головы очереди до первой незавершённой.
1217
1388
  """
1218
1389
  command = [arg for arg in ctx.args if arg != "--"]
1219
1390
  host = host.rstrip("/")
@@ -1227,6 +1398,7 @@ def watch(
1227
1398
  had_failure = False
1228
1399
  run_create_failed = False
1229
1400
  sent_batches = 0
1401
+ watch_api = _WatchApi()
1230
1402
  drain_until = 0.0
1231
1403
  announced_flush = False
1232
1404
  hold_opened = 0.0
@@ -1270,6 +1442,21 @@ def watch(
1270
1442
  click.echo(f"Также выйду после {idle_timeout} с тишины.")
1271
1443
 
1272
1444
  watch_code = 0
1445
+ session_id = None
1446
+ session_stats = {"processed": 0, "created": 0, "matched": 0, "skipped": 0}
1447
+ last_heartbeat = 0.0
1448
+ session_dirty = False
1449
+ open_payload: Dict[str, Any] = {}
1450
+ if test_run_name:
1451
+ open_payload["test_run_name"] = test_run_name
1452
+ if test_run_id and str(test_run_id).isdigit():
1453
+ open_payload["test_run_id"] = int(test_run_id)
1454
+ opened = _post_watch_session(host, project_id, token, open_payload)
1455
+ if opened and opened.get("task_id"):
1456
+ session_id = str(opened["task_id"])
1457
+ click.echo(f"Платформа показывает загрузку: {session_id}")
1458
+ else:
1459
+ click.echo("Карточка загрузки на платформе не открылась. Файлы всё равно будут отправлены.", err=True)
1273
1460
  try:
1274
1461
  if command:
1275
1462
  child["proc"] = subprocess.Popen(
@@ -1286,7 +1473,7 @@ def watch(
1286
1473
  timed_out = bool(timeout) and (now - started) >= timeout
1287
1474
  flushing = stop["requested"] or timed_out
1288
1475
 
1289
- snapshot = _snapshot_raw_files(root, include_attachments) if root.is_dir() else {}
1476
+ snapshot = _snapshot_raw_files(root, include_attachments, uploaded)
1290
1477
  if flushing and not announced_flush:
1291
1478
  pending = sum(1 for rel in snapshot if rel not in uploaded)
1292
1479
  click.echo(f"После остановки осталось файлов: {pending}.")
@@ -1365,6 +1552,7 @@ def watch(
1365
1552
  configuration_name=configuration_name,
1366
1553
  branch=branch,
1367
1554
  include_attachments=include_attachments,
1555
+ api=watch_api,
1368
1556
  )
1369
1557
  for rel, _path in chunk:
1370
1558
  uploaded.add(rel)
@@ -1375,9 +1563,15 @@ def watch(
1375
1563
  hold_opened = 0.0
1376
1564
  hold_key = None
1377
1565
 
1566
+ pairs = _poll_watch_tasks(
1567
+ inflight,
1568
+ host=host,
1569
+ project_id=project_id,
1570
+ token=token,
1571
+ api=watch_api,
1572
+ )
1378
1573
  still_running: List[str] = []
1379
- for task_id in inflight:
1380
- status = _fetch_task(host, project_id, task_id, token)
1574
+ for task_id, status in pairs:
1381
1575
  state = status.get("status", "")
1382
1576
  if state not in TERMINAL_STATUSES:
1383
1577
  still_running.append(task_id)
@@ -1393,6 +1587,11 @@ def watch(
1393
1587
  f"создано {status.get('results_created', 0)}, "
1394
1588
  f"обработано {status.get('results_processed', 0)}"
1395
1589
  )
1590
+ session_stats["processed"] += int(status.get("results_processed") or 0)
1591
+ session_stats["created"] += int(status.get("results_created") or 0)
1592
+ session_stats["matched"] += int(status.get("test_cases_matched") or 0)
1593
+ session_stats["skipped"] += int(status.get("skipped") or 0)
1594
+ session_dirty = True
1396
1595
  if state != "completed":
1397
1596
  had_failure = True
1398
1597
  for err in (status.get("errors") or [])[:3]:
@@ -1400,8 +1599,30 @@ def watch(
1400
1599
  if not known_run_id:
1401
1600
  run_create_failed = True
1402
1601
  click.echo("Тест-ран не создан, новые пачки не отправляю.", err=True)
1602
+ if len(pairs) < len(inflight):
1603
+ still_running.extend(inflight[len(pairs):])
1403
1604
  inflight = still_running
1404
1605
 
1606
+ if session_id and (
1607
+ now - last_heartbeat >= DEFAULT_WATCH_HEARTBEAT_SEC
1608
+ or (session_dirty and now - last_heartbeat >= 2)
1609
+ ):
1610
+ sent = _post_watch_session(
1611
+ host,
1612
+ project_id,
1613
+ token,
1614
+ _watch_session_payload(
1615
+ test_run_id=known_run_id,
1616
+ test_run_name=test_run_name,
1617
+ stats=session_stats,
1618
+ ),
1619
+ session_id=session_id,
1620
+ action="heartbeat",
1621
+ )
1622
+ last_heartbeat = now
1623
+ if sent is not None:
1624
+ session_dirty = False
1625
+
1405
1626
  if run_create_failed and not inflight:
1406
1627
  click.echo("watch остановлен: первая загрузка не создала тест-ран.", err=True)
1407
1628
  watch_code = 1
@@ -1426,6 +1647,25 @@ def watch(
1426
1647
  time.sleep(interval)
1427
1648
  finally:
1428
1649
  _stop_child()
1650
+ if session_id:
1651
+ finish_status = "failed" if had_failure or run_create_failed else "completed"
1652
+ closed = _post_watch_session(
1653
+ host,
1654
+ project_id,
1655
+ token,
1656
+ _watch_session_payload(
1657
+ test_run_id=known_run_id,
1658
+ test_run_name=test_run_name,
1659
+ stats=session_stats,
1660
+ status=finish_status,
1661
+ ),
1662
+ session_id=session_id,
1663
+ action="complete",
1664
+ )
1665
+ if closed is not None:
1666
+ click.echo("Платформа закрыла карточку загрузки.")
1667
+ else:
1668
+ click.echo("Не удалось закрыть карточку загрузки на платформе.", err=True)
1429
1669
 
1430
1670
  test_status = 0
1431
1671
  if child["proc"] is not None:
@@ -1436,3 +1676,7 @@ def watch(
1436
1676
  test_status = child["proc"].wait()
1437
1677
  if test_status or watch_code:
1438
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.0rc2` — предварительная версия для SaveTest 1.5.0: `pip install savetest-cli==0.3.0rc2`. Обычный `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.0rc2"