charisma-cli 0.1.2__py3-none-any.whl
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/__init__.py +3 -0
- charisma_cli/config.py +86 -0
- charisma_cli/main.py +206 -0
- charisma_cli/models.py +76 -0
- charisma_cli/parser.py +175 -0
- charisma_cli/retry.py +18 -0
- charisma_cli/subprocess_mgr.py +63 -0
- charisma_cli/uploader.py +488 -0
- charisma_cli/watcher.py +223 -0
- charisma_cli-0.1.2.dist-info/METADATA +98 -0
- charisma_cli-0.1.2.dist-info/RECORD +13 -0
- charisma_cli-0.1.2.dist-info/WHEEL +4 -0
- charisma_cli-0.1.2.dist-info/entry_points.txt +2 -0
charisma_cli/__init__.py
ADDED
charisma_cli/config.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Configuration dataclass with env var + CLI flag resolution."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Config:
|
|
11
|
+
"""Resolved configuration for charismactl.
|
|
12
|
+
|
|
13
|
+
Fields are populated via from_env() which merges CLI flag overrides
|
|
14
|
+
with environment variable fallbacks.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
endpoint: str
|
|
18
|
+
token: str
|
|
19
|
+
project: str
|
|
20
|
+
results_dir: str
|
|
21
|
+
silent: bool
|
|
22
|
+
skip_too_big: bool
|
|
23
|
+
drain_timeout: int
|
|
24
|
+
build_id: str | None
|
|
25
|
+
commit_sha: str | None
|
|
26
|
+
branch: str | None
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_env(
|
|
30
|
+
cls,
|
|
31
|
+
*,
|
|
32
|
+
endpoint: str | None = None,
|
|
33
|
+
token: str | None = None,
|
|
34
|
+
project: str | None = None,
|
|
35
|
+
results: str | None = None,
|
|
36
|
+
silent: bool = False,
|
|
37
|
+
skip_too_big: bool = False,
|
|
38
|
+
drain_timeout: int = 30,
|
|
39
|
+
) -> "Config":
|
|
40
|
+
"""Build Config by resolving CLI flags over environment variables.
|
|
41
|
+
|
|
42
|
+
Resolution order: CLI flag (if not None) > env var > default.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
endpoint: --endpoint flag override
|
|
46
|
+
token: --token flag override
|
|
47
|
+
project: --project flag override
|
|
48
|
+
results: --results flag override
|
|
49
|
+
silent: --silent flag
|
|
50
|
+
skip_too_big: --skip-too-big flag
|
|
51
|
+
drain_timeout: drain timeout in seconds
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Fully resolved Config instance.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
click.UsageError: If required fields (endpoint, token) are missing.
|
|
58
|
+
"""
|
|
59
|
+
resolved_endpoint = (endpoint or os.getenv("CHARISMA_ENDPOINT", "")).strip()
|
|
60
|
+
resolved_token = (token or os.getenv("CHARISMA_TOKEN", "")).strip()
|
|
61
|
+
resolved_project = (project or os.getenv("CHARISMA_PROJECT_ID", "")).strip()
|
|
62
|
+
resolved_results = results or os.getenv("CHARISMA_RESULTS", "allure-results")
|
|
63
|
+
|
|
64
|
+
if not resolved_endpoint:
|
|
65
|
+
raise click.UsageError(
|
|
66
|
+
"Missing required configuration: endpoint. "
|
|
67
|
+
"Set CHARISMA_ENDPOINT or pass --endpoint."
|
|
68
|
+
)
|
|
69
|
+
if not resolved_token:
|
|
70
|
+
raise click.UsageError(
|
|
71
|
+
"Missing required configuration: token. "
|
|
72
|
+
"Set CHARISMA_TOKEN or pass --token."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return cls(
|
|
76
|
+
endpoint=resolved_endpoint,
|
|
77
|
+
token=resolved_token,
|
|
78
|
+
project=resolved_project,
|
|
79
|
+
results_dir=resolved_results,
|
|
80
|
+
silent=silent,
|
|
81
|
+
skip_too_big=skip_too_big,
|
|
82
|
+
drain_timeout=drain_timeout,
|
|
83
|
+
build_id=os.getenv("BUILD_ID"),
|
|
84
|
+
commit_sha=os.getenv("COMMIT_SHA"),
|
|
85
|
+
branch=os.getenv("BRANCH"),
|
|
86
|
+
)
|
charisma_cli/main.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""CLI entry point for charismactl."""
|
|
2
|
+
|
|
3
|
+
import signal
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from queue import PriorityQueue
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from charisma_cli import __version__
|
|
11
|
+
from charisma_cli.config import Config
|
|
12
|
+
from charisma_cli.models import FileEvent
|
|
13
|
+
from charisma_cli.subprocess_mgr import SubprocessManager
|
|
14
|
+
from charisma_cli.uploader import Uploader
|
|
15
|
+
from charisma_cli.watcher import ResultsWatcher, classify_file
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.group()
|
|
19
|
+
@click.version_option(version=__version__, prog_name="charismactl")
|
|
20
|
+
def cli() -> None:
|
|
21
|
+
"""Stream allure test results to Charisma in real time."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _resolve_config(
|
|
25
|
+
endpoint: str | None,
|
|
26
|
+
token: str | None,
|
|
27
|
+
project: str,
|
|
28
|
+
results: str,
|
|
29
|
+
silent: bool,
|
|
30
|
+
skip_too_big: bool,
|
|
31
|
+
) -> Config:
|
|
32
|
+
"""Resolve CLI config from flags + env vars. Re-raises UsageError on failure."""
|
|
33
|
+
return Config.from_env(
|
|
34
|
+
endpoint=endpoint,
|
|
35
|
+
token=token,
|
|
36
|
+
project=project,
|
|
37
|
+
results=results,
|
|
38
|
+
silent=silent,
|
|
39
|
+
skip_too_big=skip_too_big,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _open_launch_or_exit(uploader: Uploader, config: Config) -> str:
|
|
44
|
+
"""Open a launch, handling failure based on silent mode.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The launch ID on success.
|
|
48
|
+
|
|
49
|
+
Exits:
|
|
50
|
+
sys.exit(1) if non-silent and launch open fails.
|
|
51
|
+
Returns empty string if silent and launch fails (caller must handle).
|
|
52
|
+
"""
|
|
53
|
+
launch_id = uploader.open_launch()
|
|
54
|
+
if launch_id is None:
|
|
55
|
+
if config.silent:
|
|
56
|
+
return ""
|
|
57
|
+
click.echo("Error: Failed to open launch. Exiting.", err=True)
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
return launch_id
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _print_summary(uploader: Uploader) -> None:
|
|
63
|
+
"""Print the upload summary to stdout."""
|
|
64
|
+
summary = uploader.summary
|
|
65
|
+
click.echo(
|
|
66
|
+
f"Summary: results_sent={summary.results_sent}, "
|
|
67
|
+
f"results_failed={summary.results_failed}, "
|
|
68
|
+
f"attachments_sent={summary.attachments_sent}, "
|
|
69
|
+
f"attachments_skipped={summary.attachments_skipped}, "
|
|
70
|
+
f"containers_sent={summary.containers_sent}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@cli.command()
|
|
75
|
+
@click.option("--endpoint", envvar="CHARISMA_ENDPOINT", default=None, help="Charisma API endpoint.")
|
|
76
|
+
@click.option("--token", envvar="CHARISMA_TOKEN", default=None, help="Bearer token.")
|
|
77
|
+
@click.option("--results", envvar="CHARISMA_RESULTS", default="allure-results", help="Allure results directory.")
|
|
78
|
+
@click.option("--project", envvar="CHARISMA_PROJECT_ID", required=True, help="Project alias.")
|
|
79
|
+
@click.option("--silent", is_flag=True, default=False, help="Don't fail if upload errors occur.")
|
|
80
|
+
@click.option("--skip-too-big", is_flag=True, default=False, help="Skip result files larger than 2MB.")
|
|
81
|
+
@click.argument("command", nargs=-1, required=True)
|
|
82
|
+
def watch(
|
|
83
|
+
endpoint: str | None,
|
|
84
|
+
token: str | None,
|
|
85
|
+
results: str,
|
|
86
|
+
project: str,
|
|
87
|
+
silent: bool,
|
|
88
|
+
skip_too_big: bool,
|
|
89
|
+
command: tuple[str, ...],
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Watch allure-results and stream to Charisma while running COMMAND.
|
|
92
|
+
|
|
93
|
+
Usage: charismactl watch --project my-project -- pytest -n 4 tests/
|
|
94
|
+
"""
|
|
95
|
+
config = _resolve_config(endpoint, token, project, results, silent, skip_too_big)
|
|
96
|
+
|
|
97
|
+
queue: PriorityQueue[FileEvent] = PriorityQueue()
|
|
98
|
+
uploader = Uploader(config, queue)
|
|
99
|
+
uploader.configure_logging()
|
|
100
|
+
|
|
101
|
+
# Open launch (Uploader owns the client)
|
|
102
|
+
launch_id = _open_launch_or_exit(uploader, config)
|
|
103
|
+
if not launch_id:
|
|
104
|
+
# Silent degradation — run subprocess without uploads
|
|
105
|
+
mgr = SubprocessManager()
|
|
106
|
+
mgr.spawn(command)
|
|
107
|
+
sys.exit(mgr.wait())
|
|
108
|
+
|
|
109
|
+
# Start watcher + uploader consumer
|
|
110
|
+
watcher = ResultsWatcher(config.results_dir, queue, config)
|
|
111
|
+
watcher.start()
|
|
112
|
+
uploader.start()
|
|
113
|
+
|
|
114
|
+
# Spawn subprocess with signal forwarding
|
|
115
|
+
mgr = SubprocessManager()
|
|
116
|
+
|
|
117
|
+
def _forward_signal(sig: int, _frame: object) -> None:
|
|
118
|
+
mgr.forward_signal(sig)
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
signal.signal(signal.SIGTERM, _forward_signal)
|
|
122
|
+
except (OSError, ValueError):
|
|
123
|
+
pass
|
|
124
|
+
try:
|
|
125
|
+
signal.signal(signal.SIGINT, _forward_signal)
|
|
126
|
+
except (OSError, ValueError):
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
mgr.spawn(command)
|
|
130
|
+
exit_code = mgr.wait()
|
|
131
|
+
|
|
132
|
+
# After subprocess exits (crash or normal): flush debouncing files and scan
|
|
133
|
+
# for anything the watcher missed entirely. This prevents data loss when
|
|
134
|
+
# parallel test workers write results in bursts just before crash.
|
|
135
|
+
watcher.flush_pending()
|
|
136
|
+
watcher.final_scan()
|
|
137
|
+
|
|
138
|
+
# Drain and close — stop watcher AFTER drain so all files are processed
|
|
139
|
+
uploader.drain(timeout=config.drain_timeout)
|
|
140
|
+
watcher.stop()
|
|
141
|
+
uploader.stop()
|
|
142
|
+
|
|
143
|
+
uploader.ensure_client()
|
|
144
|
+
uploader.close_launch()
|
|
145
|
+
if uploader._client is not None:
|
|
146
|
+
uploader._client.close()
|
|
147
|
+
uploader._client = None
|
|
148
|
+
|
|
149
|
+
_print_summary(uploader)
|
|
150
|
+
sys.exit(exit_code)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@cli.command()
|
|
154
|
+
@click.option("--endpoint", envvar="CHARISMA_ENDPOINT", default=None, help="Charisma API endpoint.")
|
|
155
|
+
@click.option("--token", envvar="CHARISMA_TOKEN", default=None, help="Bearer token.")
|
|
156
|
+
@click.option("--results", envvar="CHARISMA_RESULTS", default="allure-results", help="Allure results directory.")
|
|
157
|
+
@click.option("--project", envvar="CHARISMA_PROJECT_ID", required=True, help="Project alias.")
|
|
158
|
+
@click.option("--silent", is_flag=True, default=False, help="Don't fail if upload errors occur.")
|
|
159
|
+
@click.option("--skip-too-big", is_flag=True, default=False, help="Skip result files larger than 2MB.")
|
|
160
|
+
def upload(
|
|
161
|
+
endpoint: str | None,
|
|
162
|
+
token: str | None,
|
|
163
|
+
results: str,
|
|
164
|
+
project: str,
|
|
165
|
+
silent: bool,
|
|
166
|
+
skip_too_big: bool,
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Upload completed allure-results to Charisma (post-execution).
|
|
169
|
+
|
|
170
|
+
Usage: charismactl upload --project my-project --results allure-results
|
|
171
|
+
"""
|
|
172
|
+
config = _resolve_config(endpoint, token, project, results, silent, skip_too_big)
|
|
173
|
+
|
|
174
|
+
queue: PriorityQueue[FileEvent] = PriorityQueue()
|
|
175
|
+
uploader = Uploader(config, queue)
|
|
176
|
+
uploader.configure_logging()
|
|
177
|
+
|
|
178
|
+
# Open launch
|
|
179
|
+
launch_id = _open_launch_or_exit(uploader, config)
|
|
180
|
+
if not launch_id:
|
|
181
|
+
click.echo("Warning: Failed to open launch. No results uploaded.", err=True)
|
|
182
|
+
sys.exit(0)
|
|
183
|
+
|
|
184
|
+
# Scan existing results directory
|
|
185
|
+
results_path = Path(config.results_dir)
|
|
186
|
+
if not results_path.exists():
|
|
187
|
+
click.echo(f"Error: Results directory '{config.results_dir}' does not exist.", err=True)
|
|
188
|
+
sys.exit(1)
|
|
189
|
+
|
|
190
|
+
for filepath in sorted(results_path.iterdir()):
|
|
191
|
+
if filepath.is_file():
|
|
192
|
+
category = classify_file(filepath.name)
|
|
193
|
+
if category is not None:
|
|
194
|
+
queue.put(FileEvent(path=filepath, category=category))
|
|
195
|
+
|
|
196
|
+
# Process and drain
|
|
197
|
+
uploader.start()
|
|
198
|
+
uploader.drain(timeout=config.drain_timeout)
|
|
199
|
+
uploader.stop()
|
|
200
|
+
|
|
201
|
+
# Close launch
|
|
202
|
+
uploader.ensure_client()
|
|
203
|
+
uploader.close_launch()
|
|
204
|
+
uploader.stop()
|
|
205
|
+
|
|
206
|
+
_print_summary(uploader)
|
charisma_cli/models.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Dataclasses for internal file events, parsed results, and upload statistics."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import IntEnum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FileCategory(IntEnum):
|
|
9
|
+
"""File classification with priority ordering."""
|
|
10
|
+
|
|
11
|
+
RESULT = 0
|
|
12
|
+
CONTAINER = 1
|
|
13
|
+
ATTACHMENT = 2
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class FileEvent:
|
|
18
|
+
"""A file detected by the watcher, ready for processing."""
|
|
19
|
+
|
|
20
|
+
path: Path
|
|
21
|
+
category: FileCategory
|
|
22
|
+
|
|
23
|
+
def __lt__(self, other: "FileEvent") -> bool:
|
|
24
|
+
"""Priority queue ordering — results first."""
|
|
25
|
+
return self.category < other.category
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class CharismaResult:
|
|
30
|
+
"""A single test result in Charisma streaming API format."""
|
|
31
|
+
|
|
32
|
+
testId: str # from Allure historyId (stable across reruns)
|
|
33
|
+
outcome: str # passed | failed | broken | skipped
|
|
34
|
+
duration_ms: int # stop - start
|
|
35
|
+
name: str | None = None # short test name
|
|
36
|
+
full_name: str | None = None # Allure fullName (fully-qualified)
|
|
37
|
+
error_message: str | None = None
|
|
38
|
+
stack_trace: str | None = None
|
|
39
|
+
labels: list[dict[str, str]] = field(default_factory=list)
|
|
40
|
+
parameters: list[dict[str, str]] = field(default_factory=list)
|
|
41
|
+
started_at: int | None = None # epoch ms
|
|
42
|
+
ended_at: int | None = None # epoch ms
|
|
43
|
+
uuid: str | None = None # Allure UUID (for attachment linkage)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class AttachmentRef:
|
|
48
|
+
"""Reference to an attachment file discovered in a result."""
|
|
49
|
+
|
|
50
|
+
source: str # filename in allure-results/
|
|
51
|
+
name: str # human-readable name
|
|
52
|
+
mime_type: str # content-type
|
|
53
|
+
result_uuid: str # owning result UUID
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class ContainerData:
|
|
58
|
+
"""Parsed container with fixture steps and child references."""
|
|
59
|
+
|
|
60
|
+
uuid: str
|
|
61
|
+
name: str
|
|
62
|
+
children: list[str] # result UUIDs
|
|
63
|
+
befores: list[dict] # fixture step dicts
|
|
64
|
+
afters: list[dict] # fixture step dicts
|
|
65
|
+
received_at: float = 0.0 # time.monotonic() for timeout tracking
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class UploadSummary:
|
|
70
|
+
"""Exit report statistics."""
|
|
71
|
+
|
|
72
|
+
results_sent: int = 0
|
|
73
|
+
results_failed: int = 0
|
|
74
|
+
attachments_sent: int = 0
|
|
75
|
+
attachments_skipped: int = 0
|
|
76
|
+
containers_sent: int = 0
|
charisma_cli/parser.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Allure JSON → Charisma API format transformation.
|
|
2
|
+
|
|
3
|
+
Parses Allure result and container JSON files into internal dataclasses
|
|
4
|
+
for streaming to the Charisma ingestion API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from charisma_cli.models import AttachmentRef, CharismaResult, ContainerData
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def map_allure_status(status: str) -> str:
|
|
17
|
+
"""Map Allure status string to Charisma outcome.
|
|
18
|
+
|
|
19
|
+
Known statuses pass through unchanged: passed, failed, broken, skipped.
|
|
20
|
+
Unknown statuses also pass through unchanged (defensive behavior).
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
status: Allure status string.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Charisma outcome string.
|
|
27
|
+
"""
|
|
28
|
+
return status
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_result_file(path: Path) -> CharismaResult | None:
|
|
32
|
+
"""Parse an Allure result JSON file into a CharismaResult.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
path: Path to the Allure result JSON file.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
CharismaResult if parsing succeeds, None if the file is missing,
|
|
39
|
+
unreadable, malformed JSON, or missing required fields.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
text = path.read_text(encoding="utf-8")
|
|
43
|
+
except (OSError, FileNotFoundError):
|
|
44
|
+
logger.warning("Cannot read result file: %s", path)
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
data = json.loads(text)
|
|
49
|
+
except (json.JSONDecodeError, ValueError):
|
|
50
|
+
logger.warning("Malformed JSON in result file: %s", path)
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
if not isinstance(data, dict):
|
|
54
|
+
logger.warning("Result file is not a JSON object: %s", path)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
# Required fields
|
|
58
|
+
uuid = data.get("uuid")
|
|
59
|
+
start = data.get("start")
|
|
60
|
+
stop = data.get("stop")
|
|
61
|
+
|
|
62
|
+
if uuid is None or start is None or stop is None:
|
|
63
|
+
logger.warning("Result file missing required fields (uuid/start/stop): %s", path)
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
# testId: prefer historyId, fallback to fullName
|
|
67
|
+
history_id = data.get("historyId")
|
|
68
|
+
full_name = data.get("fullName")
|
|
69
|
+
test_id = history_id or full_name
|
|
70
|
+
|
|
71
|
+
if test_id is None:
|
|
72
|
+
logger.warning("Result file missing both historyId and fullName: %s", path)
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
# Compute duration
|
|
76
|
+
duration_ms = stop - start
|
|
77
|
+
|
|
78
|
+
# Status mapping
|
|
79
|
+
status = data.get("status", "")
|
|
80
|
+
outcome = map_allure_status(status)
|
|
81
|
+
|
|
82
|
+
# Status details
|
|
83
|
+
status_details = data.get("statusDetails") or {}
|
|
84
|
+
error_message = status_details.get("message")
|
|
85
|
+
stack_trace = status_details.get("trace")
|
|
86
|
+
|
|
87
|
+
# Labels and parameters default to empty lists
|
|
88
|
+
labels = data.get("labels", [])
|
|
89
|
+
parameters = data.get("parameters", [])
|
|
90
|
+
|
|
91
|
+
return CharismaResult(
|
|
92
|
+
testId=test_id,
|
|
93
|
+
outcome=outcome,
|
|
94
|
+
duration_ms=duration_ms,
|
|
95
|
+
name=data.get("name"),
|
|
96
|
+
full_name=full_name,
|
|
97
|
+
error_message=error_message,
|
|
98
|
+
stack_trace=stack_trace,
|
|
99
|
+
labels=labels,
|
|
100
|
+
parameters=parameters,
|
|
101
|
+
started_at=start,
|
|
102
|
+
ended_at=stop,
|
|
103
|
+
uuid=uuid,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_container_file(path: Path) -> ContainerData | None:
|
|
108
|
+
"""Parse an Allure container JSON file into a ContainerData.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
path: Path to the Allure container JSON file.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
ContainerData if parsing succeeds, None if the file is missing,
|
|
115
|
+
unreadable, malformed JSON, or missing required uuid field.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
text = path.read_text(encoding="utf-8")
|
|
119
|
+
except (OSError, FileNotFoundError):
|
|
120
|
+
logger.warning("Cannot read container file: %s", path)
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
data = json.loads(text)
|
|
125
|
+
except (json.JSONDecodeError, ValueError):
|
|
126
|
+
logger.warning("Malformed JSON in container file: %s", path)
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
if not isinstance(data, dict):
|
|
130
|
+
logger.warning("Container file is not a JSON object: %s", path)
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
# Required field
|
|
134
|
+
uuid = data.get("uuid")
|
|
135
|
+
if uuid is None:
|
|
136
|
+
logger.warning("Container file missing required uuid: %s", path)
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
return ContainerData(
|
|
140
|
+
uuid=uuid,
|
|
141
|
+
name=data.get("name", ""),
|
|
142
|
+
children=data.get("children", []),
|
|
143
|
+
befores=data.get("befores", []),
|
|
144
|
+
afters=data.get("afters", []),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def extract_attachment_refs(result_data: dict, result_uuid: str) -> list[AttachmentRef]:
|
|
149
|
+
"""Extract attachment references from an Allure result data dict.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
result_data: The raw parsed Allure result JSON dict.
|
|
153
|
+
result_uuid: The UUID of the owning result (passed as argument,
|
|
154
|
+
not taken from result_data).
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
List of AttachmentRef objects for each attachment in the result.
|
|
158
|
+
"""
|
|
159
|
+
attachments = result_data.get("attachments")
|
|
160
|
+
if not attachments:
|
|
161
|
+
return []
|
|
162
|
+
|
|
163
|
+
refs: list[AttachmentRef] = []
|
|
164
|
+
for attachment in attachments:
|
|
165
|
+
source = attachment["source"]
|
|
166
|
+
refs.append(
|
|
167
|
+
AttachmentRef(
|
|
168
|
+
source=source,
|
|
169
|
+
name=attachment.get("name", source),
|
|
170
|
+
mime_type=attachment.get("type", "application/octet-stream"),
|
|
171
|
+
result_uuid=result_uuid,
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return refs
|
charisma_cli/retry.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Exponential backoff utility and retryable HTTP status code constants."""
|
|
2
|
+
|
|
3
|
+
RETRYABLE_STATUS_CODES = frozenset({429, 500, 502, 503, 504})
|
|
4
|
+
MAX_RETRIES = 3
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def exponential_backoff(attempt: int, base: float = 1.0, factor: float = 2.0) -> float:
|
|
8
|
+
"""Calculate retry delay using exponential backoff.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
attempt: Zero-based attempt index (0, 1, 2, ...).
|
|
12
|
+
base: Base delay in seconds.
|
|
13
|
+
factor: Multiplicative factor per attempt.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
Delay in seconds: base * factor^attempt → 1s, 2s, 4s for defaults.
|
|
17
|
+
"""
|
|
18
|
+
return base * (factor ** attempt)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Subprocess spawning, signal forwarding, and exit code management."""
|
|
2
|
+
|
|
3
|
+
import signal
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SubprocessManager:
|
|
9
|
+
"""Manages the lifecycle of a test subprocess.
|
|
10
|
+
|
|
11
|
+
Spawns the command, waits for completion, and forwards OS signals
|
|
12
|
+
(SIGINT, SIGTERM) to the child process.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._process: subprocess.Popen | None = None
|
|
17
|
+
|
|
18
|
+
def spawn(self, command: tuple[str, ...]) -> None:
|
|
19
|
+
"""Spawn the subprocess.
|
|
20
|
+
|
|
21
|
+
The child inherits stdout/stderr for transparent output pass-through.
|
|
22
|
+
stdin is not inherited to avoid issues in environments where stdin
|
|
23
|
+
is not a real file descriptor (e.g. pytest capture).
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
command: Command tuple to execute (e.g. ("pytest", "-n", "4")).
|
|
27
|
+
"""
|
|
28
|
+
self._process = subprocess.Popen(
|
|
29
|
+
command,
|
|
30
|
+
stdout=None, # inherit parent stdout
|
|
31
|
+
stderr=None, # inherit parent stderr
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def wait(self) -> int:
|
|
35
|
+
"""Wait for the subprocess to terminate and return its exit code.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
The subprocess exit code, or 2 if no process was spawned.
|
|
39
|
+
"""
|
|
40
|
+
if self._process is None:
|
|
41
|
+
return 2
|
|
42
|
+
self._process.wait()
|
|
43
|
+
return self._process.returncode
|
|
44
|
+
|
|
45
|
+
def forward_signal(self, sig: signal.Signals) -> None:
|
|
46
|
+
"""Forward a signal to the child process.
|
|
47
|
+
|
|
48
|
+
On Windows, SIGTERM is implemented via process.terminate().
|
|
49
|
+
On POSIX, the signal is sent directly via os.kill.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
sig: The signal to forward (e.g. signal.SIGTERM, signal.SIGINT).
|
|
53
|
+
"""
|
|
54
|
+
if self._process is None:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
if sys.platform == "win32":
|
|
58
|
+
# Windows doesn't support POSIX signals — use terminate/kill
|
|
59
|
+
if sig in (signal.SIGTERM, signal.SIGINT):
|
|
60
|
+
self._process.terminate()
|
|
61
|
+
else:
|
|
62
|
+
import os
|
|
63
|
+
os.kill(self._process.pid, sig)
|