savetest-cli 0.3.0rc3__tar.gz → 0.3.0rc4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {savetest_cli-0.3.0rc3 → savetest_cli-0.3.0rc4}/PKG-INFO +4 -2
- savetest_cli-0.3.0rc3/savetest_cli.egg-info/PKG-INFO → savetest_cli-0.3.0rc4/README.md +3 -11
- {savetest_cli-0.3.0rc3 → savetest_cli-0.3.0rc4}/pyproject.toml +1 -1
- savetest_cli-0.3.0rc4/savetest_cli/__init__.py +1 -0
- {savetest_cli-0.3.0rc3 → savetest_cli-0.3.0rc4}/savetest_cli/cli.py +281 -35
- savetest_cli-0.3.0rc3/README.md → savetest_cli-0.3.0rc4/savetest_cli.egg-info/PKG-INFO +13 -1
- {savetest_cli-0.3.0rc3 → 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.0rc3/savetest_cli/__init__.py +0 -1
- {savetest_cli-0.3.0rc3 → savetest_cli-0.3.0rc4}/MANIFEST.in +0 -0
- {savetest_cli-0.3.0rc3 → savetest_cli-0.3.0rc4}/savetest_cli/pwa-512x512.png +0 -0
- {savetest_cli-0.3.0rc3 → savetest_cli-0.3.0rc4}/savetest_cli.egg-info/dependency_links.txt +0 -0
- {savetest_cli-0.3.0rc3 → savetest_cli-0.3.0rc4}/savetest_cli.egg-info/entry_points.txt +0 -0
- {savetest_cli-0.3.0rc3 → savetest_cli-0.3.0rc4}/savetest_cli.egg-info/requires.txt +0 -0
- {savetest_cli-0.3.0rc3 → savetest_cli-0.3.0rc4}/savetest_cli.egg-info/top_level.txt +0 -0
- {savetest_cli-0.3.0rc3 → 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.0rc3
|
|
4
|
-
Summary: CLI for SaveTest: upload Allure results as a ZIP, as raw files, or by watching a directory
|
|
5
|
-
License-Expression: MIT
|
|
6
|
-
Requires-Python: >=3.8
|
|
7
|
-
Description-Content-Type: text/markdown
|
|
8
|
-
Requires-Dist: click>=8.0
|
|
9
|
-
Requires-Dist: requests>=2.28
|
|
10
|
-
|
|
11
1
|
<p align="center">
|
|
12
2
|
<img src="savetest_cli/pwa-512x512.png" alt="SaveTest" width="128">
|
|
13
3
|
</p>
|
|
@@ -196,6 +186,8 @@ savetest upload_files ./allure-results/*-result.json ./allure-results/executor.j
|
|
|
196
186
|
|
|
197
187
|
Первая пачка создаёт тест-ран, если не передан `--test-run-id`. Следующие файлы пишутся в тот же ран: пока ран не создан, вторая пачка ждёт. Код возврата `1`, если хотя бы один импорт не завершился как `completed`.
|
|
198
188
|
|
|
189
|
+
Пачка уходит одним запросом, статусы задач в очереди тоже читаются одним запросом. Если сервер ещё старый и отвечает 404 или 405, до конца этого запуска watch сам возвращается к прежней схеме: три запроса на пачку и опрос задач по одной, с головы очереди до первой незавершённой. Уже выпущенные версии утилиты с новым сервером работают как раньше.
|
|
190
|
+
|
|
199
191
|
Вложение уезжает вместе с `*-result.json`, который на него ссылается. Результат подождёт вложение до `--attachment-wait` секунд. Пачка без файла результата не отправляется.
|
|
200
192
|
|
|
201
193
|
Привязки к фреймворку нет. После `--` watch запускает любую команду как есть и не разбирает её аргументы: pytest, JUnit, Maven, Gradle, npm или свой скрипт. Ему всё равно, кто пишет файлы. Нужны только `*-result.json` в каталоге, который указан первым аргументом. `pytest` в примерах ниже — один из вариантов, не требование.
|
|
@@ -220,7 +212,7 @@ savetest watch ./allure-results \
|
|
|
220
212
|
- Если тесты и загрузка — разные джобы, отдайте каталог `allure-results` артефактом тоже с `when: always`.
|
|
221
213
|
- `--wait` оставляйте включённым: джоба должна завершиться только когда импорт дошёл до конца.
|
|
222
214
|
- Вложения включайте только если они реально нужны. По умолчанию CLI их не шлёт.
|
|
223
|
-
- `0.3.
|
|
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"
|
|
@@ -537,17 +537,24 @@ def _is_result_file(path: Path) -> bool:
|
|
|
537
537
|
return name.endswith("-result.json") or (name.startswith("test-result-") and name.endswith(".json"))
|
|
538
538
|
|
|
539
539
|
|
|
540
|
+
def _raw_file_name_reason(name: str, *, include_attachments: bool) -> Optional[str]:
|
|
541
|
+
"""Причина пропустить файл по одному имени, без stat."""
|
|
542
|
+
if name.startswith(".") or name.lower() in _JUNK_NAMES or name == "__MACOSX":
|
|
543
|
+
return "служебный файл"
|
|
544
|
+
if name.lower().endswith(".zip"):
|
|
545
|
+
return "ZIP загружается командой upload"
|
|
546
|
+
if not include_attachments and _is_attachment(name):
|
|
547
|
+
return "вложение"
|
|
548
|
+
return None
|
|
549
|
+
|
|
550
|
+
|
|
540
551
|
def _skip_raw_file(path: Path, *, include_attachments: bool) -> Optional[str]:
|
|
541
552
|
"""None — файл берём. Иначе короткая причина пропуска."""
|
|
542
553
|
if not path.is_file():
|
|
543
554
|
return "не файл"
|
|
544
|
-
if "__MACOSX" in path.parts
|
|
555
|
+
if "__MACOSX" in path.parts:
|
|
545
556
|
return "служебный файл"
|
|
546
|
-
|
|
547
|
-
return "ZIP загружается командой upload"
|
|
548
|
-
if not include_attachments and _is_attachment(path.name):
|
|
549
|
-
return "вложение"
|
|
550
|
-
return None
|
|
557
|
+
return _raw_file_name_reason(path.name, include_attachments=include_attachments)
|
|
551
558
|
|
|
552
559
|
|
|
553
560
|
def _collect_directory(root: Path, include_attachments: bool) -> list:
|
|
@@ -835,18 +842,50 @@ def _load_json_if_complete(path: Path) -> Optional[Any]:
|
|
|
835
842
|
return None
|
|
836
843
|
|
|
837
844
|
|
|
838
|
-
def _snapshot_raw_files(
|
|
845
|
+
def _snapshot_raw_files(
|
|
846
|
+
root: Path,
|
|
847
|
+
include_attachments: bool,
|
|
848
|
+
uploaded: Optional[set] = None,
|
|
849
|
+
) -> Dict[str, Tuple[Path, int, int]]:
|
|
850
|
+
"""Файлы каталога, которые ещё не отправлены.
|
|
851
|
+
|
|
852
|
+
Уже отправленные (rel in uploaded) не stat'ятся и в снимок не попадают.
|
|
853
|
+
Имя отсекается до stat. Ключ — относительный posix-путь.
|
|
854
|
+
"""
|
|
839
855
|
found: Dict[str, Tuple[Path, int, int]] = {}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
if _skip_raw_file(file_path, include_attachments=include_attachments):
|
|
844
|
-
continue
|
|
856
|
+
done = uploaded or ()
|
|
857
|
+
|
|
858
|
+
def walk(directory: str, prefix: str) -> None:
|
|
845
859
|
try:
|
|
846
|
-
|
|
860
|
+
iterator = os.scandir(directory)
|
|
847
861
|
except OSError:
|
|
848
|
-
|
|
849
|
-
|
|
862
|
+
return
|
|
863
|
+
with iterator:
|
|
864
|
+
for entry in iterator:
|
|
865
|
+
name = entry.name
|
|
866
|
+
rel = f"{prefix}/{name}" if prefix else name
|
|
867
|
+
if rel in done:
|
|
868
|
+
continue
|
|
869
|
+
try:
|
|
870
|
+
if entry.is_dir(follow_symlinks=False):
|
|
871
|
+
if name != "__MACOSX":
|
|
872
|
+
walk(entry.path, rel)
|
|
873
|
+
continue
|
|
874
|
+
except OSError:
|
|
875
|
+
continue
|
|
876
|
+
if _raw_file_name_reason(name, include_attachments=include_attachments):
|
|
877
|
+
continue
|
|
878
|
+
if "__MACOSX" in rel.split("/"):
|
|
879
|
+
continue
|
|
880
|
+
try:
|
|
881
|
+
if not entry.is_file(follow_symlinks=False):
|
|
882
|
+
continue
|
|
883
|
+
file_stat = entry.stat(follow_symlinks=False)
|
|
884
|
+
except OSError:
|
|
885
|
+
continue
|
|
886
|
+
found[rel] = (Path(entry.path), file_stat.st_size, file_stat.st_mtime_ns)
|
|
887
|
+
|
|
888
|
+
walk(os.fspath(root), "")
|
|
850
889
|
return found
|
|
851
890
|
|
|
852
891
|
|
|
@@ -951,12 +990,23 @@ def _chunk_watch_selection(
|
|
|
951
990
|
return chunks
|
|
952
991
|
|
|
953
992
|
|
|
954
|
-
|
|
955
|
-
|
|
993
|
+
class _WatchApi:
|
|
994
|
+
"""Какие быстрые ручки ещё пробуем в этом запуске watch.
|
|
995
|
+
|
|
996
|
+
404/405 значит, что сервер старый: до конца запуска остаётся прежний путь.
|
|
997
|
+
"""
|
|
998
|
+
|
|
999
|
+
def __init__(self) -> None:
|
|
1000
|
+
self.files_batch = True
|
|
1001
|
+
self.tasks_batch = True
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
_TASK_STATUS_BATCH_LIMIT = 500
|
|
1005
|
+
_MISSING_ROUTE = frozenset({404, 405})
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _watch_form_data(
|
|
956
1009
|
*,
|
|
957
|
-
host: str,
|
|
958
|
-
token: str,
|
|
959
|
-
project_id: str,
|
|
960
1010
|
test_run_id: Optional[str],
|
|
961
1011
|
test_run_name: Optional[str],
|
|
962
1012
|
description: Optional[str],
|
|
@@ -969,18 +1019,17 @@ def _enqueue_watch_batch(
|
|
|
969
1019
|
configuration_name: Optional[str],
|
|
970
1020
|
branch: Optional[str],
|
|
971
1021
|
include_attachments: bool,
|
|
972
|
-
) -> str:
|
|
973
|
-
|
|
974
|
-
init_data: Dict[str, str] = {
|
|
1022
|
+
) -> Dict[str, str]:
|
|
1023
|
+
data: Dict[str, str] = {
|
|
975
1024
|
"include_attachments": "true" if include_attachments else "false",
|
|
976
1025
|
}
|
|
977
1026
|
if branch:
|
|
978
|
-
|
|
1027
|
+
data["git_branch"] = branch
|
|
979
1028
|
if test_run_id:
|
|
980
|
-
|
|
1029
|
+
data["test_run_id"] = str(test_run_id)
|
|
981
1030
|
else:
|
|
982
1031
|
_append_new_test_run_fields(
|
|
983
|
-
|
|
1032
|
+
data,
|
|
984
1033
|
test_run_name=test_run_name,
|
|
985
1034
|
description=description,
|
|
986
1035
|
version=version,
|
|
@@ -991,7 +1040,58 @@ def _enqueue_watch_batch(
|
|
|
991
1040
|
configuration_id=configuration_id,
|
|
992
1041
|
configuration_name=configuration_name,
|
|
993
1042
|
)
|
|
1043
|
+
return data
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _require_task_id(payload: Dict[str, Any]) -> str:
|
|
1047
|
+
task_id = payload.get("task_id")
|
|
1048
|
+
if not task_id:
|
|
1049
|
+
click.echo("Файлы приняты, но task_id не получен.", err=True)
|
|
1050
|
+
sys.exit(1)
|
|
1051
|
+
return str(task_id)
|
|
994
1052
|
|
|
1053
|
+
|
|
1054
|
+
def _try_watch_files_batch(
|
|
1055
|
+
selected: List[Tuple[str, Path]],
|
|
1056
|
+
*,
|
|
1057
|
+
host: str,
|
|
1058
|
+
project_id: str,
|
|
1059
|
+
headers: Dict[str, str],
|
|
1060
|
+
init_data: Dict[str, str],
|
|
1061
|
+
api: _WatchApi,
|
|
1062
|
+
) -> Optional[str]:
|
|
1063
|
+
"""task_id новой ручки. None — сервер её не знает, флаг уже снят."""
|
|
1064
|
+
if not api.files_batch:
|
|
1065
|
+
return None
|
|
1066
|
+
url = f"{host}/api/v1/projects/{project_id}/allure-results/files/batch"
|
|
1067
|
+
handles = []
|
|
1068
|
+
multipart = []
|
|
1069
|
+
try:
|
|
1070
|
+
for rel, path in selected:
|
|
1071
|
+
handle = open(path, "rb")
|
|
1072
|
+
handles.append(handle)
|
|
1073
|
+
multipart.append(("files", (path.name, handle, "application/octet-stream")))
|
|
1074
|
+
multipart.append(("relative_paths", (None, rel)))
|
|
1075
|
+
resp = requests.post(url, headers=headers, data=init_data, files=multipart, timeout=180)
|
|
1076
|
+
finally:
|
|
1077
|
+
for handle in handles:
|
|
1078
|
+
handle.close()
|
|
1079
|
+
if resp.status_code in _MISSING_ROUTE:
|
|
1080
|
+
api.files_batch = False
|
|
1081
|
+
click.echo("Сервер без пакетной загрузки, отправляю пачку прежним способом.")
|
|
1082
|
+
return None
|
|
1083
|
+
_check_response(resp, "watch batch")
|
|
1084
|
+
return _require_task_id(resp.json())
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def _enqueue_watch_batch_steps(
|
|
1088
|
+
selected: List[Tuple[str, Path]],
|
|
1089
|
+
*,
|
|
1090
|
+
host: str,
|
|
1091
|
+
project_id: str,
|
|
1092
|
+
headers: Dict[str, str],
|
|
1093
|
+
init_data: Dict[str, str],
|
|
1094
|
+
) -> str:
|
|
995
1095
|
resp = requests.post(
|
|
996
1096
|
f"{host}/api/v1/projects/{project_id}/allure-results/files/init",
|
|
997
1097
|
headers=headers,
|
|
@@ -1018,11 +1118,64 @@ def _enqueue_watch_batch(
|
|
|
1018
1118
|
|
|
1019
1119
|
resp = requests.post(f"{url}/complete", headers=headers, timeout=60)
|
|
1020
1120
|
_check_response(resp, "watch complete")
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1121
|
+
return _require_task_id(resp.json())
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _enqueue_watch_batch(
|
|
1125
|
+
selected: List[Tuple[str, Path]],
|
|
1126
|
+
*,
|
|
1127
|
+
host: str,
|
|
1128
|
+
token: str,
|
|
1129
|
+
project_id: str,
|
|
1130
|
+
test_run_id: Optional[str],
|
|
1131
|
+
test_run_name: Optional[str],
|
|
1132
|
+
description: Optional[str],
|
|
1133
|
+
version: Optional[str],
|
|
1134
|
+
revision: Optional[str],
|
|
1135
|
+
test_object: Optional[str],
|
|
1136
|
+
epic_id: Optional[str],
|
|
1137
|
+
epic_name: Optional[str],
|
|
1138
|
+
configuration_id: Optional[str],
|
|
1139
|
+
configuration_name: Optional[str],
|
|
1140
|
+
branch: Optional[str],
|
|
1141
|
+
include_attachments: bool,
|
|
1142
|
+
api: Optional[_WatchApi] = None,
|
|
1143
|
+
) -> str:
|
|
1144
|
+
if api is None:
|
|
1145
|
+
api = _WatchApi()
|
|
1146
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
1147
|
+
init_data = _watch_form_data(
|
|
1148
|
+
test_run_id=test_run_id,
|
|
1149
|
+
test_run_name=test_run_name,
|
|
1150
|
+
description=description,
|
|
1151
|
+
version=version,
|
|
1152
|
+
revision=revision,
|
|
1153
|
+
test_object=test_object,
|
|
1154
|
+
epic_id=epic_id,
|
|
1155
|
+
epic_name=epic_name,
|
|
1156
|
+
configuration_id=configuration_id,
|
|
1157
|
+
configuration_name=configuration_name,
|
|
1158
|
+
branch=branch,
|
|
1159
|
+
include_attachments=include_attachments,
|
|
1160
|
+
)
|
|
1161
|
+
if api.files_batch:
|
|
1162
|
+
task_id = _try_watch_files_batch(
|
|
1163
|
+
selected,
|
|
1164
|
+
host=host,
|
|
1165
|
+
project_id=project_id,
|
|
1166
|
+
headers=headers,
|
|
1167
|
+
init_data=init_data,
|
|
1168
|
+
api=api,
|
|
1169
|
+
)
|
|
1170
|
+
if task_id:
|
|
1171
|
+
return task_id
|
|
1172
|
+
return _enqueue_watch_batch_steps(
|
|
1173
|
+
selected,
|
|
1174
|
+
host=host,
|
|
1175
|
+
project_id=project_id,
|
|
1176
|
+
headers=headers,
|
|
1177
|
+
init_data=init_data,
|
|
1178
|
+
)
|
|
1026
1179
|
|
|
1027
1180
|
|
|
1028
1181
|
def _fetch_task(host: str, project_id: str, task_id: str, token: str) -> Dict[str, Any]:
|
|
@@ -1035,6 +1188,83 @@ def _fetch_task(host: str, project_id: str, task_id: str, token: str) -> Dict[st
|
|
|
1035
1188
|
return resp.json()
|
|
1036
1189
|
|
|
1037
1190
|
|
|
1191
|
+
def _task_status_items(body: Any) -> List[Dict[str, Any]]:
|
|
1192
|
+
if isinstance(body, list):
|
|
1193
|
+
items = body
|
|
1194
|
+
elif isinstance(body, dict) and isinstance(body.get("tasks"), list):
|
|
1195
|
+
items = body["tasks"]
|
|
1196
|
+
else:
|
|
1197
|
+
items = []
|
|
1198
|
+
return [item for item in items if isinstance(item, dict)]
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def _fetch_task_status_map(
|
|
1202
|
+
task_ids: List[str],
|
|
1203
|
+
*,
|
|
1204
|
+
host: str,
|
|
1205
|
+
project_id: str,
|
|
1206
|
+
token: str,
|
|
1207
|
+
api: _WatchApi,
|
|
1208
|
+
) -> Optional[Dict[str, Dict[str, Any]]]:
|
|
1209
|
+
"""Карта task_id -> статус. None — сервер без пакетной ручки."""
|
|
1210
|
+
if not api.tasks_batch or not task_ids:
|
|
1211
|
+
return None
|
|
1212
|
+
found: Dict[str, Dict[str, Any]] = {}
|
|
1213
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
1214
|
+
url = f"{host}/api/v1/projects/{project_id}/allure-results/tasks/status"
|
|
1215
|
+
for offset in range(0, len(task_ids), _TASK_STATUS_BATCH_LIMIT):
|
|
1216
|
+
chunk = task_ids[offset:offset + _TASK_STATUS_BATCH_LIMIT]
|
|
1217
|
+
resp = requests.post(url, headers=headers, json={"task_ids": chunk}, timeout=60)
|
|
1218
|
+
if resp.status_code in _MISSING_ROUTE:
|
|
1219
|
+
api.tasks_batch = False
|
|
1220
|
+
click.echo("Сервер без пакетного статуса задач, опрашиваю по одной.")
|
|
1221
|
+
return None
|
|
1222
|
+
_check_response(resp, "watch tasks")
|
|
1223
|
+
for item in _task_status_items(resp.json()):
|
|
1224
|
+
task_id = item.get("task_id")
|
|
1225
|
+
if task_id:
|
|
1226
|
+
found[str(task_id)] = item
|
|
1227
|
+
return found
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def _poll_watch_tasks(
|
|
1231
|
+
inflight: List[str],
|
|
1232
|
+
*,
|
|
1233
|
+
host: str,
|
|
1234
|
+
project_id: str,
|
|
1235
|
+
token: str,
|
|
1236
|
+
api: _WatchApi,
|
|
1237
|
+
) -> List[Tuple[str, Dict[str, Any]]]:
|
|
1238
|
+
"""Статусы inflight.
|
|
1239
|
+
|
|
1240
|
+
Новая ручка читает все сразу. Старый сервер — с головы до первой незавершённой.
|
|
1241
|
+
"""
|
|
1242
|
+
if not inflight:
|
|
1243
|
+
return []
|
|
1244
|
+
batched = _fetch_task_status_map(
|
|
1245
|
+
inflight,
|
|
1246
|
+
host=host,
|
|
1247
|
+
project_id=project_id,
|
|
1248
|
+
token=token,
|
|
1249
|
+
api=api,
|
|
1250
|
+
)
|
|
1251
|
+
if batched is not None:
|
|
1252
|
+
pairs: List[Tuple[str, Dict[str, Any]]] = []
|
|
1253
|
+
for task_id in inflight:
|
|
1254
|
+
status = batched.get(task_id)
|
|
1255
|
+
if status is None:
|
|
1256
|
+
status = _fetch_task(host, project_id, task_id, token)
|
|
1257
|
+
pairs.append((task_id, status))
|
|
1258
|
+
return pairs
|
|
1259
|
+
pairs = []
|
|
1260
|
+
for task_id in inflight:
|
|
1261
|
+
status = _fetch_task(host, project_id, task_id, token)
|
|
1262
|
+
pairs.append((task_id, status))
|
|
1263
|
+
if status.get("status", "") not in TERMINAL_STATUSES:
|
|
1264
|
+
break
|
|
1265
|
+
return pairs
|
|
1266
|
+
|
|
1267
|
+
|
|
1038
1268
|
def _watch_session_url(host: str, project_id: str, session_id: Optional[str] = None, action: str = "") -> str:
|
|
1039
1269
|
url = f"{host}/api/v1/projects/{project_id}/allure-results/watch/sessions"
|
|
1040
1270
|
if session_id:
|
|
@@ -1152,7 +1382,9 @@ def watch(
|
|
|
1152
1382
|
Готовые файлы не уходят по одному. Если они появляются подряд, watch держит их
|
|
1153
1383
|
--batch-window секунд после последнего нового и отправляет вместе. Дольше
|
|
1154
1384
|
--batch-max-wait секунд пачка не копится. Предел одной отправки — 200 файлов
|
|
1155
|
-
или 32 МБ.
|
|
1385
|
+
или 32 МБ. Если сервер умеет, пачка и статусы задач уходят одним запросом.
|
|
1386
|
+
Ответ 404 или 405 — до конца этого запуска прежний путь: три запроса на пачку
|
|
1387
|
+
и опрос задач с головы очереди до первой незавершённой.
|
|
1156
1388
|
"""
|
|
1157
1389
|
command = [arg for arg in ctx.args if arg != "--"]
|
|
1158
1390
|
host = host.rstrip("/")
|
|
@@ -1166,6 +1398,7 @@ def watch(
|
|
|
1166
1398
|
had_failure = False
|
|
1167
1399
|
run_create_failed = False
|
|
1168
1400
|
sent_batches = 0
|
|
1401
|
+
watch_api = _WatchApi()
|
|
1169
1402
|
drain_until = 0.0
|
|
1170
1403
|
announced_flush = False
|
|
1171
1404
|
hold_opened = 0.0
|
|
@@ -1240,7 +1473,7 @@ def watch(
|
|
|
1240
1473
|
timed_out = bool(timeout) and (now - started) >= timeout
|
|
1241
1474
|
flushing = stop["requested"] or timed_out
|
|
1242
1475
|
|
|
1243
|
-
snapshot = _snapshot_raw_files(root, include_attachments
|
|
1476
|
+
snapshot = _snapshot_raw_files(root, include_attachments, uploaded)
|
|
1244
1477
|
if flushing and not announced_flush:
|
|
1245
1478
|
pending = sum(1 for rel in snapshot if rel not in uploaded)
|
|
1246
1479
|
click.echo(f"После остановки осталось файлов: {pending}.")
|
|
@@ -1319,6 +1552,7 @@ def watch(
|
|
|
1319
1552
|
configuration_name=configuration_name,
|
|
1320
1553
|
branch=branch,
|
|
1321
1554
|
include_attachments=include_attachments,
|
|
1555
|
+
api=watch_api,
|
|
1322
1556
|
)
|
|
1323
1557
|
for rel, _path in chunk:
|
|
1324
1558
|
uploaded.add(rel)
|
|
@@ -1329,9 +1563,15 @@ def watch(
|
|
|
1329
1563
|
hold_opened = 0.0
|
|
1330
1564
|
hold_key = None
|
|
1331
1565
|
|
|
1566
|
+
pairs = _poll_watch_tasks(
|
|
1567
|
+
inflight,
|
|
1568
|
+
host=host,
|
|
1569
|
+
project_id=project_id,
|
|
1570
|
+
token=token,
|
|
1571
|
+
api=watch_api,
|
|
1572
|
+
)
|
|
1332
1573
|
still_running: List[str] = []
|
|
1333
|
-
for task_id in
|
|
1334
|
-
status = _fetch_task(host, project_id, task_id, token)
|
|
1574
|
+
for task_id, status in pairs:
|
|
1335
1575
|
state = status.get("status", "")
|
|
1336
1576
|
if state not in TERMINAL_STATUSES:
|
|
1337
1577
|
still_running.append(task_id)
|
|
@@ -1359,6 +1599,8 @@ def watch(
|
|
|
1359
1599
|
if not known_run_id:
|
|
1360
1600
|
run_create_failed = True
|
|
1361
1601
|
click.echo("Тест-ран не создан, новые пачки не отправляю.", err=True)
|
|
1602
|
+
if len(pairs) < len(inflight):
|
|
1603
|
+
still_running.extend(inflight[len(pairs):])
|
|
1362
1604
|
inflight = still_running
|
|
1363
1605
|
|
|
1364
1606
|
if session_id and (
|
|
@@ -1434,3 +1676,7 @@ def watch(
|
|
|
1434
1676
|
test_status = child["proc"].wait()
|
|
1435
1677
|
if test_status or watch_code:
|
|
1436
1678
|
sys.exit(test_status or watch_code)
|
|
1679
|
+
|
|
1680
|
+
|
|
1681
|
+
if __name__ == "__main__":
|
|
1682
|
+
cli()
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: savetest-cli
|
|
3
|
+
Version: 0.3.0rc4
|
|
4
|
+
Summary: CLI for SaveTest: upload Allure results as a ZIP, as raw files, or by watching a directory
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: click>=8.0
|
|
9
|
+
Requires-Dist: requests>=2.28
|
|
10
|
+
|
|
1
11
|
<p align="center">
|
|
2
12
|
<img src="savetest_cli/pwa-512x512.png" alt="SaveTest" width="128">
|
|
3
13
|
</p>
|
|
@@ -186,6 +196,8 @@ savetest upload_files ./allure-results/*-result.json ./allure-results/executor.j
|
|
|
186
196
|
|
|
187
197
|
Первая пачка создаёт тест-ран, если не передан `--test-run-id`. Следующие файлы пишутся в тот же ран: пока ран не создан, вторая пачка ждёт. Код возврата `1`, если хотя бы один импорт не завершился как `completed`.
|
|
188
198
|
|
|
199
|
+
Пачка уходит одним запросом, статусы задач в очереди тоже читаются одним запросом. Если сервер ещё старый и отвечает 404 или 405, до конца этого запуска watch сам возвращается к прежней схеме: три запроса на пачку и опрос задач по одной, с головы очереди до первой незавершённой. Уже выпущенные версии утилиты с новым сервером работают как раньше.
|
|
200
|
+
|
|
189
201
|
Вложение уезжает вместе с `*-result.json`, который на него ссылается. Результат подождёт вложение до `--attachment-wait` секунд. Пачка без файла результата не отправляется.
|
|
190
202
|
|
|
191
203
|
Привязки к фреймворку нет. После `--` watch запускает любую команду как есть и не разбирает её аргументы: pytest, JUnit, Maven, Gradle, npm или свой скрипт. Ему всё равно, кто пишет файлы. Нужны только `*-result.json` в каталоге, который указан первым аргументом. `pytest` в примерах ниже — один из вариантов, не требование.
|
|
@@ -210,7 +222,7 @@ savetest watch ./allure-results \
|
|
|
210
222
|
- Если тесты и загрузка — разные джобы, отдайте каталог `allure-results` артефактом тоже с `when: always`.
|
|
211
223
|
- `--wait` оставляйте включённым: джоба должна завершиться только когда импорт дошёл до конца.
|
|
212
224
|
- Вложения включайте только если они реально нужны. По умолчанию CLI их не шлёт.
|
|
213
|
-
- `0.3.
|
|
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.0rc3"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|