charisma-cli 0.2.2__tar.gz → 0.3.0__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.
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/PKG-INFO +1 -1
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/pyproject.toml +1 -1
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/src/charisma_cli/config.py +11 -6
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/src/charisma_cli/main.py +26 -2
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/src/charisma_cli/models.py +55 -18
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/src/charisma_cli/uploader.py +235 -60
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_cli.py +16 -0
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_config.py +45 -0
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_uploader.py +414 -32
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/.gitignore +0 -0
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/README.md +0 -0
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/src/charisma_cli/__init__.py +0 -0
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/src/charisma_cli/launch_url.py +0 -0
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/src/charisma_cli/parser.py +0 -0
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/src/charisma_cli/retry.py +0 -0
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/src/charisma_cli/subprocess_mgr.py +0 -0
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/src/charisma_cli/watcher.py +0 -0
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/__init__.py +0 -0
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/conftest.py +0 -0
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_integration.py +0 -0
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_launch_url.py +0 -0
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_models.py +0 -0
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_parser.py +0 -0
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_retry.py +0 -0
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_silent_mode.py +0 -0
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_subprocess_mgr.py +0 -0
- {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_watcher.py +0 -0
|
@@ -24,6 +24,7 @@ class Config:
|
|
|
24
24
|
build_id: str | None
|
|
25
25
|
commit_sha: str | None
|
|
26
26
|
branch: str | None
|
|
27
|
+
run_key: str | None = None
|
|
27
28
|
|
|
28
29
|
@classmethod
|
|
29
30
|
def from_env(
|
|
@@ -36,6 +37,7 @@ class Config:
|
|
|
36
37
|
silent: bool = False,
|
|
37
38
|
skip_too_big: bool = False,
|
|
38
39
|
drain_timeout: int = 30,
|
|
40
|
+
run_key: str | None = None,
|
|
39
41
|
) -> "Config":
|
|
40
42
|
"""Build Config by resolving CLI flags over environment variables.
|
|
41
43
|
|
|
@@ -49,6 +51,11 @@ class Config:
|
|
|
49
51
|
silent: --silent flag
|
|
50
52
|
skip_too_big: --skip-too-big flag
|
|
51
53
|
drain_timeout: drain timeout in seconds
|
|
54
|
+
run_key: --run-key flag override. Client-supplied merge key: the
|
|
55
|
+
backend derives the launch id as uuid5(project/run_key) and
|
|
56
|
+
get-or-creates, so reruns and parallel producers sharing a key
|
|
57
|
+
land in one launch. Empty/whitespace resolves to None (a new
|
|
58
|
+
random launch, unchanged behavior).
|
|
52
59
|
|
|
53
60
|
Returns:
|
|
54
61
|
Fully resolved Config instance.
|
|
@@ -60,17 +67,14 @@ class Config:
|
|
|
60
67
|
resolved_token = (token or os.getenv("CHARISMA_TOKEN", "")).strip()
|
|
61
68
|
resolved_project = (project or os.getenv("CHARISMA_PROJECT_ID", "")).strip()
|
|
62
69
|
resolved_results = results or os.getenv("CHARISMA_RESULTS", "allure-results")
|
|
70
|
+
resolved_run_key = (run_key or os.getenv("CHARISMA_RUN_KEY", "")).strip() or None
|
|
63
71
|
|
|
64
72
|
if not resolved_endpoint:
|
|
65
73
|
raise click.UsageError(
|
|
66
|
-
"Missing required configuration: endpoint. "
|
|
67
|
-
"Set CHARISMA_ENDPOINT or pass --endpoint."
|
|
74
|
+
"Missing required configuration: endpoint. Set CHARISMA_ENDPOINT or pass --endpoint."
|
|
68
75
|
)
|
|
69
76
|
if not resolved_token:
|
|
70
|
-
raise click.UsageError(
|
|
71
|
-
"Missing required configuration: token. "
|
|
72
|
-
"Set CHARISMA_TOKEN or pass --token."
|
|
73
|
-
)
|
|
77
|
+
raise click.UsageError("Missing required configuration: token. Set CHARISMA_TOKEN or pass --token.")
|
|
74
78
|
|
|
75
79
|
return cls(
|
|
76
80
|
endpoint=resolved_endpoint,
|
|
@@ -83,4 +87,5 @@ class Config:
|
|
|
83
87
|
build_id=os.getenv("BUILD_ID"),
|
|
84
88
|
commit_sha=os.getenv("COMMIT_SHA"),
|
|
85
89
|
branch=os.getenv("BRANCH"),
|
|
90
|
+
run_key=resolved_run_key,
|
|
86
91
|
)
|
|
@@ -39,6 +39,7 @@ def _resolve_config(
|
|
|
39
39
|
results: str,
|
|
40
40
|
silent: bool,
|
|
41
41
|
skip_too_big: bool,
|
|
42
|
+
run_key: str | None = None,
|
|
42
43
|
) -> Config:
|
|
43
44
|
"""Resolve CLI config from flags + env vars. Re-raises UsageError on failure."""
|
|
44
45
|
return Config.from_env(
|
|
@@ -48,6 +49,7 @@ def _resolve_config(
|
|
|
48
49
|
results=results,
|
|
49
50
|
silent=silent,
|
|
50
51
|
skip_too_big=skip_too_big,
|
|
52
|
+
run_key=run_key,
|
|
51
53
|
)
|
|
52
54
|
|
|
53
55
|
|
|
@@ -140,6 +142,16 @@ def _print_summary(uploader: Uploader) -> None:
|
|
|
140
142
|
@click.option("--project", envvar="CHARISMA_PROJECT_ID", required=True, help="Project alias.")
|
|
141
143
|
@click.option("--silent", is_flag=True, default=False, help="Don't fail if upload errors occur.")
|
|
142
144
|
@click.option("--skip-too-big", is_flag=True, default=False, help="Skip result files larger than 2MB.")
|
|
145
|
+
@click.option(
|
|
146
|
+
"--run-key",
|
|
147
|
+
envvar="CHARISMA_RUN_KEY",
|
|
148
|
+
default=None,
|
|
149
|
+
help="Run identity / merge key (NOT the build id). Producers sharing this key "
|
|
150
|
+
"stream into one launch (launch id = uuid5(project/run-key)) — powers "
|
|
151
|
+
"rerun-in-place and parallel workers; reruns record retry attempts. Use your "
|
|
152
|
+
"CI run/pipeline id (e.g. GITHUB_RUN_ID) so retries merge but separate runs "
|
|
153
|
+
"stay separate. Omit for a new launch.",
|
|
154
|
+
)
|
|
143
155
|
@click.argument("command", nargs=-1, required=True)
|
|
144
156
|
def watch(
|
|
145
157
|
endpoint: str | None,
|
|
@@ -148,13 +160,14 @@ def watch(
|
|
|
148
160
|
project: str,
|
|
149
161
|
silent: bool,
|
|
150
162
|
skip_too_big: bool,
|
|
163
|
+
run_key: str | None,
|
|
151
164
|
command: tuple[str, ...],
|
|
152
165
|
) -> None:
|
|
153
166
|
"""Watch allure-results and stream to Charisma while running COMMAND.
|
|
154
167
|
|
|
155
168
|
Usage: charismactl watch --project my-project -- pytest -n 4 tests/
|
|
156
169
|
"""
|
|
157
|
-
config = _resolve_config(endpoint, token, project, results, silent, skip_too_big)
|
|
170
|
+
config = _resolve_config(endpoint, token, project, results, silent, skip_too_big, run_key)
|
|
158
171
|
|
|
159
172
|
queue: PriorityQueue[FileEvent] = PriorityQueue()
|
|
160
173
|
uploader = Uploader(config, queue)
|
|
@@ -229,6 +242,16 @@ def watch(
|
|
|
229
242
|
@click.option("--project", envvar="CHARISMA_PROJECT_ID", required=True, help="Project alias.")
|
|
230
243
|
@click.option("--silent", is_flag=True, default=False, help="Don't fail if upload errors occur.")
|
|
231
244
|
@click.option("--skip-too-big", is_flag=True, default=False, help="Skip result files larger than 2MB.")
|
|
245
|
+
@click.option(
|
|
246
|
+
"--run-key",
|
|
247
|
+
envvar="CHARISMA_RUN_KEY",
|
|
248
|
+
default=None,
|
|
249
|
+
help="Run identity / merge key (NOT the build id). Producers sharing this key "
|
|
250
|
+
"stream into one launch (launch id = uuid5(project/run-key)) — powers "
|
|
251
|
+
"rerun-in-place and parallel workers; reruns record retry attempts. Use your "
|
|
252
|
+
"CI run/pipeline id (e.g. GITHUB_RUN_ID) so retries merge but separate runs "
|
|
253
|
+
"stay separate. Omit for a new launch.",
|
|
254
|
+
)
|
|
232
255
|
def upload(
|
|
233
256
|
endpoint: str | None,
|
|
234
257
|
token: str | None,
|
|
@@ -236,12 +259,13 @@ def upload(
|
|
|
236
259
|
project: str,
|
|
237
260
|
silent: bool,
|
|
238
261
|
skip_too_big: bool,
|
|
262
|
+
run_key: str | None,
|
|
239
263
|
) -> None:
|
|
240
264
|
"""Upload completed allure-results to Charisma (post-execution).
|
|
241
265
|
|
|
242
266
|
Usage: charismactl upload --project my-project --results allure-results
|
|
243
267
|
"""
|
|
244
|
-
config = _resolve_config(endpoint, token, project, results, silent, skip_too_big)
|
|
268
|
+
config = _resolve_config(endpoint, token, project, results, silent, skip_too_big, run_key)
|
|
245
269
|
|
|
246
270
|
queue: PriorityQueue[FileEvent] = PriorityQueue()
|
|
247
271
|
uploader = Uploader(config, queue)
|
|
@@ -4,32 +4,69 @@ from dataclasses import dataclass, field
|
|
|
4
4
|
from enum import IntEnum
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from typing import Any
|
|
7
|
-
from uuid import NAMESPACE_URL, uuid5
|
|
8
7
|
|
|
8
|
+
# Backend field-size contract (mirror of backend
|
|
9
|
+
# models/testresults.py::MAX_ERROR_MESSAGE_BYTES / MAX_STACK_TRACE_BYTES).
|
|
10
|
+
# The streaming ingestion API rejects the WHOLE batch (400 INVALID_INPUT) if any
|
|
11
|
+
# result's error_message > 10KB or stack_trace > 100KB (UTF-8 bytes). Keep these
|
|
12
|
+
# byte-identical to the backend; oversized fields spill to an attachment rather
|
|
13
|
+
# than truncate (see uploader._spill_oversized_field).
|
|
14
|
+
MAX_ERROR_MESSAGE_BYTES = 10 * 1024 # 10KB
|
|
15
|
+
MAX_STACK_TRACE_BYTES = 100 * 1024 # 100KB
|
|
9
16
|
|
|
10
|
-
def backend_result_id(launch_id: str, test_id: str) -> str:
|
|
11
|
-
"""Derive the result identity the Charisma backend stores and validates against.
|
|
12
17
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
# Cap for a synthesized attachment filename derived from a test's historyId,
|
|
19
|
+
# which is arbitrary client-supplied text (Allure historyId, or fullName
|
|
20
|
+
# fallback) that may contain path separators or be very long.
|
|
21
|
+
MAX_ATTACHMENT_FILENAME_LENGTH = 200
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def safe_attachment_filename(history_id: str, suffix: str) -> str:
|
|
25
|
+
"""Build a safe ``text/plain`` attachment filename from a test identity.
|
|
26
|
+
|
|
27
|
+
``history_id`` is arbitrary client-supplied text (Allure ``historyId``, or
|
|
28
|
+
the ``fullName`` fallback) and can contain path separators (``/``, ``\\``),
|
|
29
|
+
``..``, control characters, or be very long. This strips it to a basename,
|
|
30
|
+
replaces any remaining unsafe characters, and length-caps the stem so the
|
|
31
|
+
multipart filename sent to the backend is well-formed and bounded.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
history_id: The owning result's test identity.
|
|
35
|
+
suffix: A short descriptor appended to the stem (e.g. ``error-message``).
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
A sanitized ``{stem}-{suffix}.txt`` filename.
|
|
39
|
+
"""
|
|
40
|
+
# Drop any path structure, then keep only filename-safe characters.
|
|
41
|
+
base = history_id.replace("\\", "/").rsplit("/", 1)[-1]
|
|
42
|
+
safe = "".join(c if (c.isalnum() or c in "-_.") else "_" for c in base)
|
|
43
|
+
stem = safe[:MAX_ATTACHMENT_FILENAME_LENGTH] or "result"
|
|
44
|
+
return f"{stem}-{suffix}.txt"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def head_snippet(text: str, max_bytes: int) -> str:
|
|
48
|
+
"""Return the longest UTF-8 prefix of ``text`` that fits in ``max_bytes``.
|
|
49
|
+
|
|
50
|
+
Cuts on a character boundary — never splits a multibyte code point — by
|
|
51
|
+
encoding, slicing the byte string, and decoding with ``errors="ignore"`` to
|
|
52
|
+
drop a trailing partial character. Used to keep an inline head of an
|
|
53
|
+
oversized field (the full value goes to an attachment).
|
|
20
54
|
|
|
21
55
|
Args:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
``test_id`` in the results batch. This is the Allure ``historyId``,
|
|
25
|
-
falling back to ``fullName`` when ``historyId`` is absent (see
|
|
26
|
-
``parser.parse_result_file``). Matching the backend requires only
|
|
27
|
-
that both sides key off this same value, whichever it is.
|
|
56
|
+
text: The source string.
|
|
57
|
+
max_bytes: Maximum size of the returned prefix in UTF-8 bytes.
|
|
28
58
|
|
|
29
59
|
Returns:
|
|
30
|
-
|
|
60
|
+
A prefix of ``text`` whose UTF-8 encoding is ``<= max_bytes`` bytes.
|
|
31
61
|
"""
|
|
32
|
-
|
|
62
|
+
encoded = text.encode("utf-8")
|
|
63
|
+
# Clamp: a non-positive budget must yield an empty prefix, never a Python
|
|
64
|
+
# negative slice (encoded[:-n]) which would return almost the whole string.
|
|
65
|
+
if max_bytes <= 0:
|
|
66
|
+
return ""
|
|
67
|
+
if len(encoded) <= max_bytes:
|
|
68
|
+
return text
|
|
69
|
+
return encoded[:max_bytes].decode("utf-8", errors="ignore")
|
|
33
70
|
|
|
34
71
|
|
|
35
72
|
class FileCategory(IntEnum):
|