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.
- {savetest_cli-0.3.0rc2 → savetest_cli-0.3.0rc4}/PKG-INFO +4 -2
- savetest_cli-0.3.0rc2/savetest_cli.egg-info/PKG-INFO → savetest_cli-0.3.0rc4/README.md +3 -11
- {savetest_cli-0.3.0rc2 → savetest_cli-0.3.0rc4}/pyproject.toml +1 -1
- savetest_cli-0.3.0rc4/savetest_cli/__init__.py +1 -0
- {savetest_cli-0.3.0rc2 → savetest_cli-0.3.0rc4}/savetest_cli/cli.py +413 -169
- savetest_cli-0.3.0rc2/README.md → savetest_cli-0.3.0rc4/savetest_cli.egg-info/PKG-INFO +13 -1
- {savetest_cli-0.3.0rc2 → savetest_cli-0.3.0rc4}/savetest_cli.egg-info/SOURCES.txt +2 -1
- savetest_cli-0.3.0rc4/tests/test_watch_fastpath.py +219 -0
- savetest_cli-0.3.0rc2/savetest_cli/__init__.py +0 -1
- {savetest_cli-0.3.0rc2 → savetest_cli-0.3.0rc4}/MANIFEST.in +0 -0
- {savetest_cli-0.3.0rc2 → savetest_cli-0.3.0rc4}/savetest_cli/pwa-512x512.png +0 -0
- {savetest_cli-0.3.0rc2 → savetest_cli-0.3.0rc4}/savetest_cli.egg-info/dependency_links.txt +0 -0
- {savetest_cli-0.3.0rc2 → savetest_cli-0.3.0rc4}/savetest_cli.egg-info/entry_points.txt +0 -0
- {savetest_cli-0.3.0rc2 → savetest_cli-0.3.0rc4}/savetest_cli.egg-info/requires.txt +0 -0
- {savetest_cli-0.3.0rc2 → savetest_cli-0.3.0rc4}/savetest_cli.egg-info/top_level.txt +0 -0
- {savetest_cli-0.3.0rc2 → savetest_cli-0.3.0rc4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: savetest-cli
|
|
3
|
-
Version: 0.3.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
555
|
+
if "__MACOSX" in path.parts:
|
|
544
556
|
return "служебный файл"
|
|
545
|
-
|
|
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(
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
|
|
860
|
+
iterator = os.scandir(directory)
|
|
866
861
|
except OSError:
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
-
|
|
883
|
-
|
|
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
|
-
|
|
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
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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
|
-
|
|
1062
|
-
|
|
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
|
-
|
|
1074
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1027
|
+
data["git_branch"] = branch
|
|
1098
1028
|
if test_run_id:
|
|
1099
|
-
|
|
1029
|
+
data["test_run_id"] = str(test_run_id)
|
|
1100
1030
|
else:
|
|
1101
1031
|
_append_new_test_run_fields(
|
|
1102
|
-
|
|
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
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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="
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
|
@@ -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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|