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.
@@ -0,0 +1,3 @@
1
+ """charisma-cli: Stream allure results to Charisma in real time."""
2
+
3
+ __version__ = "0.1.2"
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)