pytest-charisma 0.3.1__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
+ """pytest-charisma: A pytest plugin that streams test results to the Charisma ingestion API."""
2
+
3
+ __version__ = "0.3.1"
@@ -0,0 +1,121 @@
1
+ """HTTP client wrapper for Charisma API communication.
2
+
3
+ Provides CharismaClient for opening launches and flushing test result batches
4
+ to the Charisma streaming ingestion API. Uses httpx with connection pooling
5
+ and configured timeouts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ import httpx
13
+
14
+ logger = logging.getLogger("pytest-charisma")
15
+
16
+
17
+ class CharismaClient:
18
+ """HTTP client for the Charisma ingestion API.
19
+
20
+ Wraps httpx.Client with connection pooling, Bearer token auth,
21
+ and configured timeouts (5s connect, 10s read).
22
+
23
+ Args:
24
+ base_url: Base URL of the Charisma API (e.g. "https://api.charisma.dev").
25
+ token: Bearer token for API authentication.
26
+ """
27
+
28
+ CONNECT_TIMEOUT = 5.0
29
+ READ_TIMEOUT = 10.0
30
+
31
+ def __init__(self, base_url: str, token: str) -> None:
32
+ self._http = httpx.Client(
33
+ base_url=base_url,
34
+ headers={
35
+ "Authorization": f"Bearer {token}",
36
+ "Content-Type": "application/json",
37
+ },
38
+ timeout=httpx.Timeout(self.CONNECT_TIMEOUT, read=self.READ_TIMEOUT),
39
+ )
40
+
41
+ def open_launch(self, payload: dict) -> str:
42
+ """Open a new launch session.
43
+
44
+ POST /api/v1/launches with the given payload. Returns the launchId
45
+ from the response body. Raises on any non-2xx response or missing launchId.
46
+
47
+ Args:
48
+ payload: Launch request body containing projectAlias, expectedTests,
49
+ and optional fields (buildId, commitSha, branch, etc.).
50
+
51
+ Returns:
52
+ The launchId string from the API response.
53
+
54
+ Raises:
55
+ httpx.HTTPStatusError: On non-2xx response status.
56
+ ValueError: If the response body does not contain a launchId.
57
+ """
58
+ url = "/api/v1/launches"
59
+ response = self._http.post(url, json=payload)
60
+ if response.status_code >= 400:
61
+ logger.warning("open_launch failed: status=%d, body=%s", response.status_code, response.text[:500])
62
+ response.raise_for_status()
63
+ data = response.json()
64
+ launch_id = data.get("launchId")
65
+ if not launch_id:
66
+ raise ValueError(f"API response missing 'launchId': {data}")
67
+ return launch_id
68
+
69
+ def flush_batch(self, launch_id: str, results: list[dict]) -> bool:
70
+ """Flush a batch of test results to the API.
71
+
72
+ POST /api/v1/launches/{launch_id}/results with results wrapped in
73
+ a {"results": [...]} payload. Returns True on HTTP 200, False on
74
+ any failure (HTTP errors, network errors). Never raises.
75
+
76
+ Args:
77
+ launch_id: The launch session ID to append results to.
78
+ results: List of serialized test result dicts.
79
+
80
+ Returns:
81
+ True if the API returned 200, False otherwise.
82
+ """
83
+ try:
84
+ response = self._http.post(
85
+ f"/api/v1/launches/{launch_id}/results",
86
+ json={"results": results},
87
+ )
88
+ if response.status_code != 200:
89
+ logger.warning("flush_batch got status=%d, body=%s", response.status_code, response.text[:200])
90
+ return response.status_code == 200
91
+ except Exception as e:
92
+ logger.warning("flush_batch exception: %s", e)
93
+ return False
94
+
95
+ def close_launch(self, launch_id: str) -> bool:
96
+ """Close a streaming launch, finalizing its status.
97
+
98
+ POST /api/v1/launches/{launch_id}/close. Signals the API that no more
99
+ results will be sent and the launch should compute its final status
100
+ from whatever results were received. Idempotent.
101
+
102
+ Args:
103
+ launch_id: The launch session ID to close.
104
+
105
+ Returns:
106
+ True if the API returned 200, False otherwise.
107
+ """
108
+ try:
109
+ response = self._http.post(
110
+ f"/api/v1/launches/{launch_id}/close",
111
+ )
112
+ if response.status_code != 200:
113
+ logger.warning("close_launch got status=%d, body=%s", response.status_code, response.text[:200])
114
+ return response.status_code == 200
115
+ except Exception as e:
116
+ logger.warning("close_launch exception: %s", e)
117
+ return False
118
+
119
+ def close(self) -> None:
120
+ """Close the underlying httpx.Client and release connection pool."""
121
+ self._http.close()
@@ -0,0 +1,265 @@
1
+ """Configuration resolution for pytest-charisma.
2
+
3
+ Resolves plugin configuration from three sources with priority: CLI > ini > env.
4
+ Emits pytest warnings for missing required fields and clamps batch_size to [1, 50].
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import warnings
12
+ from dataclasses import dataclass
13
+
14
+ import pytest
15
+
16
+ logger = logging.getLogger("pytest-charisma")
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Configuration option descriptors
20
+ # ---------------------------------------------------------------------------
21
+
22
+ # Each tuple: (field_name, cli_flag, ini_key, env_var)
23
+ _OPTIONS: list[tuple[str, str, str, str]] = [
24
+ ("url", "--charisma-url", "charisma_url", "CHARISMA_URL"),
25
+ ("token", "--charisma-token", "charisma_token", "CHARISMA_TOKEN"),
26
+ ("project_alias", "--charisma-project-alias", "charisma_project_alias", "CHARISMA_PROJECT_ALIAS"),
27
+ ("batch_size", "--charisma-batch-size", "charisma_batch_size", "CHARISMA_BATCH_SIZE"),
28
+ ("build_id", "--charisma-build-id", "charisma_build_id", "CHARISMA_BUILD_ID"),
29
+ ("commit_sha", "--charisma-commit-sha", "charisma_commit_sha", "CHARISMA_COMMIT_SHA"),
30
+ ("branch", "--charisma-branch", "charisma_branch", "CHARISMA_BRANCH"),
31
+ ("component_alias", "--charisma-component", "charisma_component", "CHARISMA_COMPONENT"),
32
+ ("source_url", "--charisma-source-url", "charisma_source_url", "CHARISMA_SOURCE_URL"),
33
+ ]
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # CharismaConfig dataclass
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class CharismaConfig:
43
+ """Immutable configuration for the pytest-charisma plugin.
44
+
45
+ All fields are resolved from CLI > ini > env priority via the resolve() classmethod.
46
+ """
47
+
48
+ url: str
49
+ token: str
50
+ project_alias: str
51
+ batch_size: int = 20
52
+ build_id: str | None = None
53
+ commit_sha: str | None = None
54
+ branch: str | None = None
55
+ component_alias: str | None = None
56
+ source_url: str | None = None
57
+
58
+ @classmethod
59
+ def resolve(cls, config: pytest.Config) -> CharismaConfig | None:
60
+ """Resolve configuration from CLI > ini > env with priority.
61
+
62
+ Returns None if plugin should be disabled (missing url or required fields).
63
+ Emits pytest warnings for validation issues.
64
+
65
+ Args:
66
+ config: The pytest Config object providing getoption() and getini().
67
+
68
+ Returns:
69
+ A CharismaConfig instance if all required fields are present, else None.
70
+ """
71
+ resolved: dict[str, str | None] = {}
72
+
73
+ for field_name, cli_flag, ini_key, env_var in _OPTIONS:
74
+ value = _resolve_option(config, cli_flag, ini_key, env_var)
75
+ resolved[field_name] = value
76
+
77
+ # --- URL check: if absent, plugin is silently disabled ---
78
+ url = resolved["url"]
79
+ if not url:
80
+ return None
81
+
82
+ # --- Token check: emit warning if missing ---
83
+ token = resolved["token"]
84
+ if not token:
85
+ logger.warning("token missing — plugin DISABLED")
86
+ warnings.warn(
87
+ "charisma: token is required when url is configured. Plugin disabled.",
88
+ pytest.PytestConfigWarning,
89
+ stacklevel=2,
90
+ )
91
+ return None
92
+
93
+ # --- Project alias check: emit warning if missing ---
94
+ project_alias = resolved["project_alias"]
95
+ if not project_alias:
96
+ logger.warning("project_alias missing — plugin DISABLED")
97
+ warnings.warn(
98
+ "charisma: project alias is required when url is configured. Plugin disabled.",
99
+ pytest.PytestConfigWarning,
100
+ stacklevel=2,
101
+ )
102
+ return None
103
+
104
+ # --- Batch size: parse and clamp to [1, 50] ---
105
+ batch_size = _parse_batch_size(resolved["batch_size"])
106
+
107
+ return cls(
108
+ url=url,
109
+ token=token,
110
+ project_alias=project_alias,
111
+ batch_size=batch_size,
112
+ build_id=resolved["build_id"] or None,
113
+ commit_sha=resolved["commit_sha"] or None,
114
+ branch=resolved["branch"] or None,
115
+ component_alias=resolved["component_alias"] or None,
116
+ source_url=resolved["source_url"] or None,
117
+ )
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # CLI option registration helper
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ def register_cli_options(parser: pytest.Parser) -> None:
126
+ """Register all --charisma-* CLI options and ini values with pytest.
127
+
128
+ Called from pytest_addoption hook in plugin.py.
129
+
130
+ Args:
131
+ parser: The pytest argument parser.
132
+ """
133
+ group = parser.getgroup("charisma", "Charisma test reporting")
134
+
135
+ group.addoption(
136
+ "--charisma-url",
137
+ dest="charisma_url",
138
+ default=None,
139
+ help="Charisma API base URL",
140
+ )
141
+ group.addoption(
142
+ "--charisma-token",
143
+ dest="charisma_token",
144
+ default=None,
145
+ help="Charisma API authentication token",
146
+ )
147
+ group.addoption(
148
+ "--charisma-project-alias",
149
+ dest="charisma_project_alias",
150
+ default=None,
151
+ help="Charisma project alias",
152
+ )
153
+ group.addoption(
154
+ "--charisma-batch-size",
155
+ dest="charisma_batch_size",
156
+ default=None,
157
+ help="Number of results per batch (1-50, default: 20)",
158
+ )
159
+ group.addoption(
160
+ "--charisma-build-id",
161
+ dest="charisma_build_id",
162
+ default=None,
163
+ help="Build identifier (e.g., CI build number)",
164
+ )
165
+ group.addoption(
166
+ "--charisma-commit-sha",
167
+ dest="charisma_commit_sha",
168
+ default=None,
169
+ help="Git commit SHA",
170
+ )
171
+ group.addoption(
172
+ "--charisma-branch",
173
+ dest="charisma_branch",
174
+ default=None,
175
+ help="Git branch name",
176
+ )
177
+ group.addoption(
178
+ "--charisma-component",
179
+ dest="charisma_component",
180
+ default=None,
181
+ help="Component alias within the project",
182
+ )
183
+ group.addoption(
184
+ "--charisma-source-url",
185
+ dest="charisma_source_url",
186
+ default=None,
187
+ help="CI/CD source URL for this run",
188
+ )
189
+
190
+ # Register ini values
191
+ parser.addini("charisma_url", help="Charisma API base URL", default="")
192
+ parser.addini("charisma_token", help="Charisma API authentication token", default="")
193
+ parser.addini("charisma_project_alias", help="Charisma project alias", default="")
194
+ parser.addini("charisma_batch_size", help="Number of results per batch (1-50)", default="")
195
+ parser.addini("charisma_build_id", help="Build identifier", default="")
196
+ parser.addini("charisma_commit_sha", help="Git commit SHA", default="")
197
+ parser.addini("charisma_branch", help="Git branch name", default="")
198
+ parser.addini("charisma_component", help="Component alias", default="")
199
+ parser.addini("charisma_source_url", help="CI/CD source URL", default="")
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # Internal helpers
204
+ # ---------------------------------------------------------------------------
205
+
206
+
207
+ def _resolve_option(
208
+ config: pytest.Config,
209
+ cli_flag: str,
210
+ ini_key: str,
211
+ env_var: str,
212
+ ) -> str | None:
213
+ """Resolve a single option from CLI > ini > env.
214
+
215
+ Returns the first non-None, non-empty value found, or None.
216
+ """
217
+ # 1. CLI argument (highest priority)
218
+ cli_value = config.getoption(cli_flag, default=None)
219
+ if cli_value is not None and str(cli_value).strip():
220
+ return str(cli_value).strip()
221
+
222
+ # 2. ini value (medium priority)
223
+ ini_value = config.getini(ini_key)
224
+ if ini_value is not None and str(ini_value).strip():
225
+ return str(ini_value).strip()
226
+
227
+ # 3. Environment variable (lowest priority)
228
+ env_value = os.environ.get(env_var)
229
+ if env_value is not None and env_value.strip():
230
+ return env_value.strip()
231
+
232
+ return None
233
+
234
+
235
+ def _parse_batch_size(raw: str | None) -> int:
236
+ """Parse and clamp batch_size to [1, 50].
237
+
238
+ Returns 20 (default) if raw is None or not a valid integer.
239
+ Emits a pytest warning if clamping occurs.
240
+ """
241
+ if raw is None:
242
+ return 20
243
+
244
+ try:
245
+ value = int(raw)
246
+ except (ValueError, TypeError):
247
+ return 20
248
+
249
+ if value < 1:
250
+ warnings.warn(
251
+ f"charisma: batch_size {value} is below minimum, clamped to 1.",
252
+ pytest.PytestConfigWarning,
253
+ stacklevel=3,
254
+ )
255
+ return 1
256
+
257
+ if value > 50:
258
+ warnings.warn(
259
+ f"charisma: batch_size {value} exceeds maximum, clamped to 50.",
260
+ pytest.PytestConfigWarning,
261
+ stacklevel=3,
262
+ )
263
+ return 50
264
+
265
+ return value
@@ -0,0 +1,120 @@
1
+ """Data classes for test result payloads.
2
+
3
+ Provides TestResultPayload dataclass and mapping from pytest report objects
4
+ to the Charisma API result schema.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timedelta
11
+ from typing import Any
12
+
13
+
14
+ @dataclass
15
+ class TestResultPayload:
16
+ """A single test result payload for the Charisma streaming API.
17
+
18
+ Attributes:
19
+ testId: Pytest nodeid (e.g., "tests/test_login.py::TestAuth::test_valid").
20
+ outcome: Mapped outcome (passed, failed, skipped, broken).
21
+ duration_ms: Test duration in milliseconds (rounded).
22
+ started_at: ISO 8601 UTC timestamp when the test started.
23
+ ended_at: ISO 8601 UTC timestamp when the test ended.
24
+ error_message: Error description on failure/broken, None otherwise.
25
+ stack_trace: Full traceback on failure/broken, None otherwise.
26
+ stdout: Captured stdout if non-empty, None otherwise.
27
+ stderr: Captured stderr if non-empty, None otherwise.
28
+ """
29
+
30
+ testId: str
31
+ outcome: str
32
+ duration_ms: int
33
+ started_at: str
34
+ ended_at: str
35
+ error_message: str | None = None
36
+ stack_trace: str | None = None
37
+ stdout: str | None = None
38
+ stderr: str | None = None
39
+
40
+ def to_dict(self) -> dict[str, Any]:
41
+ """Serialize to dict for JSON encoding, omitting None fields."""
42
+ d: dict[str, Any] = {
43
+ "testId": self.testId,
44
+ "outcome": self.outcome,
45
+ "duration_ms": self.duration_ms,
46
+ "started_at": self.started_at,
47
+ "ended_at": self.ended_at,
48
+ }
49
+ if self.error_message is not None:
50
+ d["error_message"] = self.error_message
51
+ if self.stack_trace is not None:
52
+ d["stack_trace"] = self.stack_trace
53
+ if self.stdout is not None:
54
+ d["stdout"] = self.stdout
55
+ if self.stderr is not None:
56
+ d["stderr"] = self.stderr
57
+ return d
58
+
59
+
60
+ def _map_outcome(when: str, outcome: str) -> str:
61
+ """Map pytest report phase and outcome to Charisma outcome.
62
+
63
+ Only call/failed and setup|teardown/failed are meaningful for the API.
64
+ Setup/teardown failures map to 'broken'. Call-phase outcomes pass through.
65
+
66
+ Args:
67
+ when: The pytest report phase (setup, call, teardown).
68
+ outcome: The pytest outcome (passed, failed, skipped).
69
+
70
+ Returns:
71
+ Charisma outcome string (passed, failed, skipped, broken).
72
+ """
73
+ if when in ("setup", "teardown") and outcome == "failed":
74
+ return "broken"
75
+ return outcome
76
+
77
+
78
+ def map_report_to_payload(report: Any, started_at: datetime) -> TestResultPayload:
79
+ """Map a pytest TestReport to a TestResultPayload.
80
+
81
+ Args:
82
+ report: A pytest TestReport (or mock with nodeid, when, outcome,
83
+ duration, longrepr, capstdout, capstderr attributes).
84
+ started_at: The UTC datetime when the test started executing.
85
+
86
+ Returns:
87
+ A TestResultPayload ready for serialization and API submission.
88
+ """
89
+ outcome = _map_outcome(report.when, report.outcome)
90
+ duration_ms = round(report.duration * 1000)
91
+
92
+ started_at_str = started_at.isoformat()
93
+ ended_at = started_at + timedelta(seconds=report.duration)
94
+ ended_at_str = ended_at.isoformat()
95
+
96
+ # Capture error info only on failure or broken outcomes
97
+ error_message: str | None = None
98
+ stack_trace: str | None = None
99
+ if outcome in ("failed", "broken") and report.longrepr is not None:
100
+ longrepr_str = str(report.longrepr)
101
+ # error_message: last line (the actual error), stack_trace: full repr
102
+ lines = longrepr_str.strip().splitlines()
103
+ error_message = lines[-1] if lines else longrepr_str
104
+ stack_trace = longrepr_str
105
+
106
+ # Capture stdout/stderr only if non-empty
107
+ stdout: str | None = report.capstdout if report.capstdout else None
108
+ stderr: str | None = report.capstderr if report.capstderr else None
109
+
110
+ return TestResultPayload(
111
+ testId=report.nodeid,
112
+ outcome=outcome,
113
+ duration_ms=duration_ms,
114
+ started_at=started_at_str,
115
+ ended_at=ended_at_str,
116
+ error_message=error_message,
117
+ stack_trace=stack_trace,
118
+ stdout=stdout,
119
+ stderr=stderr,
120
+ )
@@ -0,0 +1,536 @@
1
+ """Pytest hooks and plugin entry point for pytest-charisma.
2
+
3
+ Supports both single-process and pytest-xdist parallel execution.
4
+ In xdist mode, the controller opens the launch and shares the launch_id
5
+ with workers via workerinput. Each worker streams results independently
6
+ to the shared launch.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import atexit
12
+ import logging
13
+ import sys
14
+ from datetime import datetime, timezone
15
+
16
+ import pytest
17
+
18
+ from pytest_charisma.client import CharismaClient
19
+ from pytest_charisma.config import CharismaConfig, register_cli_options
20
+ from pytest_charisma.models import TestResultPayload, map_report_to_payload
21
+ from pytest_charisma.worker import BackgroundWorker
22
+
23
+ logger = logging.getLogger("pytest-charisma")
24
+
25
+
26
+ def _setup_logging() -> None:
27
+ """Configure pytest-charisma logger to output to stderr at INFO level.
28
+
29
+ Ensures logs are always visible in CI regardless of pytest capture settings.
30
+ Only adds a handler if one hasn't been added already.
31
+ """
32
+ if not logger.handlers:
33
+ handler = logging.StreamHandler(sys.stderr)
34
+ handler.setFormatter(logging.Formatter("[%(name)s] %(message)s"))
35
+ logger.addHandler(handler)
36
+ logger.setLevel(logging.INFO)
37
+
38
+
39
+ def pytest_addoption(parser: pytest.Parser) -> None:
40
+ """Register all --charisma-* CLI options and ini values."""
41
+ register_cli_options(parser)
42
+
43
+
44
+ def pytest_configure(config: pytest.Config) -> None:
45
+ """Resolve config and register the appropriate plugin variant.
46
+
47
+ Routes to CharismaWorkerPlugin (xdist worker), CharismaControllerPlugin
48
+ (xdist controller), or CharismaPlugin (single-process) based on the
49
+ detected execution mode.
50
+ """
51
+ _setup_logging()
52
+
53
+ charisma_config = CharismaConfig.resolve(config)
54
+ if charisma_config is None:
55
+ return
56
+
57
+ is_worker = _is_xdist_worker(config)
58
+ is_controller = _is_xdist_controller(config)
59
+
60
+ if is_worker:
61
+ plugin = CharismaWorkerPlugin(charisma_config)
62
+ config.pluginmanager.register(plugin, "charisma-plugin")
63
+ elif is_controller:
64
+ plugin = CharismaControllerPlugin(charisma_config)
65
+ config.pluginmanager.register(plugin, "charisma-plugin")
66
+ logger.info(
67
+ "config: url=%s, project=%s, component=%s, batch_size=%d",
68
+ charisma_config.url,
69
+ charisma_config.project_alias,
70
+ charisma_config.component_alias or "—",
71
+ charisma_config.batch_size,
72
+ )
73
+ else:
74
+ plugin = CharismaPlugin(charisma_config)
75
+ config.pluginmanager.register(plugin, "charisma-plugin")
76
+ logger.info(
77
+ "config: url=%s, project=%s, component=%s, batch_size=%d",
78
+ charisma_config.url,
79
+ charisma_config.project_alias,
80
+ charisma_config.component_alias or "—",
81
+ charisma_config.batch_size,
82
+ )
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # xdist detection helpers
87
+ # ---------------------------------------------------------------------------
88
+
89
+
90
+ def _is_xdist_worker(config: pytest.Config) -> bool:
91
+ """Return True if running as a pytest-xdist worker process.
92
+
93
+ xdist sets config.workerinput as a dict on worker processes.
94
+ We check both existence and that it's a dict to avoid false positives
95
+ with mock objects.
96
+ """
97
+ workerinput = getattr(config, "workerinput", None)
98
+ return isinstance(workerinput, dict)
99
+
100
+
101
+ def _is_xdist_controller(config: pytest.Config) -> bool:
102
+ """Return True if running as a pytest-xdist controller process.
103
+
104
+ Checks that xdist is loaded AND tests will actually be distributed
105
+ (numprocesses > 0 or numprocesses == "auto"). This avoids false positives
106
+ when xdist is installed but not active (no -n flag).
107
+ """
108
+ try:
109
+ has_xdist = config.pluginmanager.hasplugin("xdist")
110
+ if not has_xdist:
111
+ return False
112
+ # xdist is installed but might not be active (e.g., -p no:xdist or no -n flag)
113
+ num_processes = getattr(config.option, "numprocesses", None)
114
+ if num_processes is None:
115
+ return False
116
+ # numprocesses can be "auto" (str) or an int; must be a real value
117
+ if isinstance(num_processes, str):
118
+ return not _is_xdist_worker(config)
119
+ if isinstance(num_processes, int):
120
+ return num_processes > 0 and not _is_xdist_worker(config)
121
+ # Unexpected type (e.g., MagicMock) — not xdist
122
+ return False
123
+ except AttributeError:
124
+ logger.warning("xdist detection failed (missing attribute)")
125
+ return False
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Shared helpers
130
+ # ---------------------------------------------------------------------------
131
+
132
+
133
+ def _build_launch_payload(config: CharismaConfig, expected_tests: int) -> dict[str, str | int]:
134
+ """Build the open-launch request payload from config.
135
+
136
+ Args:
137
+ config: Resolved CharismaConfig instance.
138
+ expected_tests: Number of collected test items.
139
+
140
+ Returns:
141
+ Dict payload for the open-launch API call.
142
+ """
143
+ payload: dict[str, str | int] = {
144
+ "projectAlias": config.project_alias,
145
+ "expectedTests": expected_tests,
146
+ }
147
+ if config.build_id:
148
+ payload["buildId"] = config.build_id
149
+ if config.commit_sha:
150
+ payload["commitSha"] = config.commit_sha
151
+ if config.branch:
152
+ payload["branch"] = config.branch
153
+ if config.component_alias:
154
+ payload["componentAlias"] = config.component_alias
155
+ if config.source_url:
156
+ payload["sourceUrl"] = config.source_url
157
+ return payload
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Streaming mixin (shared report handling + session finish logic)
162
+ # ---------------------------------------------------------------------------
163
+
164
+
165
+ class _StreamingMixin:
166
+ """Shared logic for plugins that stream test results.
167
+
168
+ Provides report buffering, batch flushing, and session finish handling.
169
+ Subclasses must set _config, _worker, _launch_id, _buffer, _start_times.
170
+ """
171
+
172
+ _config: CharismaConfig
173
+ _worker: BackgroundWorker | None
174
+ _launch_id: str | None
175
+ _buffer: list[TestResultPayload]
176
+ _start_times: dict[str, datetime]
177
+ _is_launch_owner: bool # True for single-process plugin, False for xdist workers
178
+ _results_count: int
179
+ _reported_nodeids: set # Track which tests were actually reported
180
+ _collected_nodeids: list # All collected test item nodeids
181
+ _session_finished: bool # Guard against double-flush (atexit + sessionfinish)
182
+
183
+ def _atexit_flush(self) -> None:
184
+ """Emergency flush called by atexit — sends any buffered results.
185
+
186
+ Registered via ``atexit.register`` in ``__init__``. When pytest crashes
187
+ (segfault, KeyboardInterrupt, worker killed), pytest_sessionfinish may
188
+ never fire, so this handler ensures buffered results are flushed to the
189
+ server before the process exits.
190
+ """
191
+ if getattr(self, "_session_finished", False):
192
+ return # Already handled by pytest_sessionfinish
193
+ try:
194
+ self._handle_session_finish()
195
+ except Exception:
196
+ # Best-effort — don't crash during interpreter shutdown
197
+ pass
198
+
199
+ def _handle_report(self, report: pytest.TestReport) -> None:
200
+ """Process a test report — buffer and flush at batch_size threshold."""
201
+ # Process: call phase (any outcome), setup/teardown failures, setup skips.
202
+ # Setup skips represent tests that never reach the call phase (e.g., skip
203
+ # markers, xfail during fixture) — they must be counted toward expectedTests
204
+ # so the launch can transition out of 'receiving' state.
205
+ if (
206
+ report.when == "call"
207
+ or (report.when == "setup" and report.outcome in ("failed", "skipped"))
208
+ or (report.when == "teardown" and report.outcome == "failed")
209
+ ):
210
+ pass
211
+ else:
212
+ return
213
+
214
+ # Skip if streaming is not active
215
+ if self._launch_id is None or self._worker is None:
216
+ return
217
+
218
+ # Get start time (fallback to now if not recorded)
219
+ started_at = self._start_times.get(report.nodeid, datetime.now(timezone.utc))
220
+
221
+ # Map report to payload and remove consumed start time
222
+ payload = map_report_to_payload(report, started_at)
223
+ self._start_times.pop(report.nodeid, None)
224
+ self._buffer.append(payload)
225
+ self._results_count += 1
226
+ self._reported_nodeids.add(report.nodeid)
227
+
228
+ # Flush at batch_size threshold
229
+ if len(self._buffer) >= self._config.batch_size:
230
+ batch = self._buffer[:]
231
+ self._buffer.clear()
232
+ self._worker.submit_batch(batch)
233
+
234
+ def _handle_session_finish(self) -> None:
235
+ """Flush remaining buffer, report unreported skips, close launch, stop worker."""
236
+ if getattr(self, "_session_finished", False):
237
+ return # Already handled (guard against double-call from atexit)
238
+ self._session_finished = True
239
+
240
+ if self._worker is None:
241
+ return
242
+
243
+ # Flush remaining buffer (only if non-empty)
244
+ if self._buffer:
245
+ batch = self._buffer[:]
246
+ self._buffer.clear()
247
+ self._worker.submit_batch(batch)
248
+
249
+ # Report unreported tests as skipped — ONLY for single-process mode.
250
+ # In xdist, each worker collects ALL items but executes only a subset,
251
+ # so _collected_nodeids contains tests assigned to OTHER workers.
252
+ # Backfilling those would overwrite real results from other workers.
253
+ if getattr(self, "_is_launch_owner", False):
254
+ unreported = [nid for nid in getattr(self, "_collected_nodeids", []) if nid not in self._reported_nodeids]
255
+ if unreported:
256
+ now = datetime.now(timezone.utc).isoformat()
257
+ skip_batch = [
258
+ TestResultPayload(
259
+ testId=nid,
260
+ outcome="skipped",
261
+ duration_ms=0,
262
+ started_at=now,
263
+ ended_at=now,
264
+ error_message=None,
265
+ stack_trace=None,
266
+ stdout=None,
267
+ stderr=None,
268
+ )
269
+ for nid in unreported
270
+ ]
271
+ for i in range(0, len(skip_batch), self._config.batch_size):
272
+ self._worker.submit_batch(skip_batch[i : i + self._config.batch_size])
273
+ self._results_count += len(unreported)
274
+ logger.info("reported %d unreported tests as skipped", len(unreported))
275
+
276
+ # Stop worker with 30s timeout (ensures all batches are flushed)
277
+ self._worker.stop(timeout=30.0)
278
+ if self._results_count > 0:
279
+ logger.info("dumped %d test results", self._results_count)
280
+
281
+ # Close the launch to finalize status (handles expected_tests mismatch)
282
+ # Only the launch owner (single-process or xdist controller) closes.
283
+ # Must use a FRESH client because worker.stop() closes the shared one.
284
+ if self._launch_id and getattr(self, "_is_launch_owner", False):
285
+ from pytest_charisma.client import CharismaClient
286
+
287
+ close_client = CharismaClient(self._config.url, self._config.token)
288
+ try:
289
+ success = close_client.close_launch(self._launch_id)
290
+ if success:
291
+ logger.info("launch closed: id=%s", self._launch_id)
292
+ else:
293
+ logger.warning("close_launch failed for id=%s", self._launch_id)
294
+ finally:
295
+ close_client.close()
296
+
297
+
298
+ # ---------------------------------------------------------------------------
299
+ # Single-process plugin (original behavior)
300
+ # ---------------------------------------------------------------------------
301
+
302
+
303
+ class CharismaPlugin(_StreamingMixin):
304
+ """Pytest plugin that streams test results to the Charisma API.
305
+
306
+ Used in single-process mode (no xdist). Opens a launch after collection,
307
+ batches results during execution, and flushes at session end.
308
+
309
+ Args:
310
+ config: Resolved CharismaConfig instance.
311
+ """
312
+
313
+ def __init__(self, config: CharismaConfig) -> None:
314
+ self._config = config
315
+ self._client: CharismaClient | None = None
316
+ self._worker: BackgroundWorker | None = None
317
+ self._launch_id: str | None = None
318
+ self._buffer: list[TestResultPayload] = []
319
+ self._start_times: dict[str, datetime] = {}
320
+ self._results_count: int = 0
321
+ self._is_launch_owner: bool = True # Single-process owns the launch
322
+ self._reported_nodeids: set[str] = set()
323
+ self._collected_nodeids: list[str] = []
324
+ self._session_finished: bool = False
325
+ atexit.register(self._atexit_flush)
326
+
327
+ def pytest_runtest_logstart(self, nodeid: str, location: tuple[str, int | None, str]) -> None:
328
+ """Record test start time."""
329
+ self._start_times[nodeid] = datetime.now(timezone.utc)
330
+
331
+ def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
332
+ """Capture test results and batch them."""
333
+ try:
334
+ self._handle_report(report)
335
+ except Exception:
336
+ logger.warning("error processing report", exc_info=True)
337
+
338
+ def pytest_collection_modifyitems(self, config: pytest.Config, items: list[pytest.Item]) -> None:
339
+ """Open launch session after collection."""
340
+ try:
341
+ self._handle_collection(items)
342
+ except Exception:
343
+ logger.warning("error during collection hook", exc_info=True)
344
+
345
+ def pytest_sessionfinish(self, session: pytest.Session, exitstatus: int) -> None:
346
+ """Flush remaining buffer and drain worker."""
347
+ try:
348
+ self._handle_session_finish()
349
+ except Exception:
350
+ logger.warning("error during session finish", exc_info=True)
351
+
352
+ def _handle_collection(self, items: list[pytest.Item]) -> None:
353
+ """Open launch session after collection (internal)."""
354
+ if not items:
355
+ return
356
+
357
+ # Track collected nodeids for unreported-skip detection at session end
358
+ self._collected_nodeids = [item.nodeid for item in items]
359
+
360
+ client = CharismaClient(self._config.url, self._config.token)
361
+ payload = _build_launch_payload(self._config, len(items))
362
+
363
+ try:
364
+ launch_id = client.open_launch(payload)
365
+ except Exception:
366
+ logger.warning("open-launch FAILED, streaming disabled", exc_info=True)
367
+ client.close()
368
+ return
369
+
370
+ self._client = client
371
+ self._launch_id = launch_id
372
+ logger.info("launch opened: id=%s (%d tests, single-process)", launch_id, len(items))
373
+
374
+ # Create and start worker
375
+ worker = BackgroundWorker(client, launch_id)
376
+ worker.start()
377
+ self._worker = worker
378
+
379
+
380
+ # ---------------------------------------------------------------------------
381
+ # xdist controller plugin
382
+ # ---------------------------------------------------------------------------
383
+
384
+
385
+ class CharismaControllerPlugin:
386
+ """Pytest plugin for the xdist controller process.
387
+
388
+ Opens the launch eagerly in pytest_configure_node (first call) by
389
+ performing a manual test collection to get the accurate test count.
390
+ This is necessary because xdist does not collect on the controller —
391
+ workers collect independently — but we need the count before workers
392
+ spawn to pass launch_id via workerinput.
393
+
394
+ Args:
395
+ config: Resolved CharismaConfig instance.
396
+ """
397
+
398
+ def __init__(self, config: CharismaConfig) -> None:
399
+ self._config = config
400
+ self._client: CharismaClient | None = None
401
+ self._launch_id: str | None = None
402
+ self._launch_opened = False
403
+
404
+ def pytest_configure_node(self, node: object) -> None:
405
+ """Open launch on first call (with real test count), pass launch_id to workers.
406
+
407
+ Args:
408
+ node: xdist WorkerController instance.
409
+ """
410
+ if not self._launch_opened:
411
+ self._launch_opened = True
412
+ self._open_launch_with_collection(node)
413
+
414
+ if self._launch_id is not None:
415
+ node.workerinput["charisma_launch_id"] = self._launch_id # type: ignore[attr-defined]
416
+
417
+ def pytest_sessionfinish(self, session: pytest.Session, exitstatus: int) -> None:
418
+ """Close the launch and HTTP client on the controller when session ends."""
419
+ if self._client is not None:
420
+ # Close the launch to finalize status
421
+ if self._launch_id:
422
+ success = self._client.close_launch(self._launch_id)
423
+ if success:
424
+ logger.info("launch closed by controller: id=%s", self._launch_id)
425
+ else:
426
+ logger.warning("close_launch failed for id=%s", self._launch_id)
427
+ self._client.close()
428
+
429
+ def _open_launch_with_collection(self, node: object) -> None:
430
+ """Collect tests manually to get count, then open launch."""
431
+ config = node.config # type: ignore[attr-defined]
432
+ test_count = self._collect_test_count(config)
433
+ if test_count == 0:
434
+ return
435
+
436
+ client = CharismaClient(self._config.url, self._config.token)
437
+ payload = _build_launch_payload(self._config, expected_tests=test_count)
438
+
439
+ try:
440
+ launch_id = client.open_launch(payload)
441
+ except Exception:
442
+ logger.warning("open-launch FAILED, streaming disabled", exc_info=True)
443
+ client.close()
444
+ return
445
+
446
+ self._client = client
447
+ self._launch_id = launch_id
448
+ logger.info("launch opened: id=%s (%d tests, xdist controller)", launch_id, test_count)
449
+
450
+ def _collect_test_count(self, config: pytest.Config) -> int:
451
+ """Collect tests using the existing session to get item count."""
452
+ try:
453
+ session = config.pluginmanager.get_plugin("session")
454
+ if session is None:
455
+ return 0
456
+ session.perform_collect()
457
+ return len(session.items)
458
+ except Exception:
459
+ logger.warning("manual collection failed", exc_info=True)
460
+ return 0
461
+
462
+
463
+ # ---------------------------------------------------------------------------
464
+ # xdist worker plugin
465
+ # ---------------------------------------------------------------------------
466
+
467
+
468
+ class CharismaWorkerPlugin(_StreamingMixin):
469
+ """Pytest plugin for xdist worker processes.
470
+
471
+ Receives the launch_id from the controller via workerinput and streams
472
+ test results to the shared launch. Each worker has its own HTTP client
473
+ and BackgroundWorker thread.
474
+
475
+ Args:
476
+ config: Resolved CharismaConfig instance.
477
+ """
478
+
479
+ def __init__(self, config: CharismaConfig) -> None:
480
+ self._config = config
481
+ self._client: CharismaClient | None = None
482
+ self._worker: BackgroundWorker | None = None
483
+ self._launch_id: str | None = None
484
+ self._buffer: list[TestResultPayload] = []
485
+ self._start_times: dict[str, datetime] = {}
486
+ self._results_count: int = 0
487
+ self._is_launch_owner: bool = False # Workers don't own the launch
488
+ self._reported_nodeids: set[str] = set()
489
+ self._collected_nodeids: list[str] = []
490
+ self._session_finished: bool = False
491
+ atexit.register(self._atexit_flush)
492
+
493
+ @pytest.hookimpl(trylast=True)
494
+ def pytest_collection_modifyitems(self, config: pytest.Config, items: list[pytest.Item]) -> None:
495
+ """Initialize streaming on the worker after collection."""
496
+ try:
497
+ self._collected_nodeids = [item.nodeid for item in items]
498
+ self._initialize_from_workerinput(config)
499
+ except Exception:
500
+ logger.warning("worker initialization failed", exc_info=True)
501
+
502
+ def pytest_runtest_logstart(self, nodeid: str, location: tuple[str, int | None, str]) -> None:
503
+ """Record test start time."""
504
+ self._start_times[nodeid] = datetime.now(timezone.utc)
505
+
506
+ def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
507
+ """Capture test results and batch them."""
508
+ try:
509
+ self._handle_report(report)
510
+ except Exception:
511
+ logger.warning("[worker] error processing report", exc_info=True)
512
+
513
+ def pytest_sessionfinish(self, session: pytest.Session, exitstatus: int) -> None:
514
+ """Flush remaining buffer and drain worker."""
515
+ try:
516
+ self._handle_session_finish()
517
+ except Exception:
518
+ logger.warning("[worker] error during session finish", exc_info=True)
519
+
520
+ def _initialize_from_workerinput(self, config: pytest.Config) -> None:
521
+ """Read launch_id from workerinput and start streaming."""
522
+ workerinput = getattr(config, "workerinput", None)
523
+ if workerinput is None:
524
+ return
525
+
526
+ launch_id = workerinput.get("charisma_launch_id")
527
+ if not launch_id:
528
+ return
529
+
530
+ client = CharismaClient(self._config.url, self._config.token)
531
+ self._client = client
532
+ self._launch_id = launch_id
533
+
534
+ worker = BackgroundWorker(client, launch_id)
535
+ worker.start()
536
+ self._worker = worker
@@ -0,0 +1,154 @@
1
+ """Background worker thread for batching and flushing results.
2
+
3
+ Provides BackgroundWorker class that processes test result batches
4
+ in a daemon thread, with retry logic and circuit-breaker behavior.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import queue
11
+ import threading
12
+
13
+ from pytest_charisma.client import CharismaClient
14
+ from pytest_charisma.models import TestResultPayload
15
+
16
+ logger = logging.getLogger("pytest-charisma")
17
+
18
+
19
+ class BackgroundWorker:
20
+ """Background daemon thread that flushes test result batches to the API.
21
+
22
+ Dequeues batches from a thread-safe queue, serializes them, and sends
23
+ via CharismaClient. Implements retry-once and circuit-breaker (3 consecutive
24
+ failures → disabled).
25
+
26
+ Args:
27
+ client: CharismaClient instance for HTTP communication.
28
+ launch_id: The launch session ID for append requests.
29
+ """
30
+
31
+ MAX_CONSECUTIVE_FAILURES = 3
32
+
33
+ def __init__(self, client: CharismaClient, launch_id: str) -> None:
34
+ self._queue: queue.Queue[list[TestResultPayload] | None] = queue.Queue()
35
+ self._client = client
36
+ self._launch_id = launch_id
37
+ self._thread = threading.Thread(target=self._run, daemon=True)
38
+ self._consecutive_failures = 0
39
+ self._lock = threading.Lock()
40
+ self._disabled = False
41
+ self._dropped_count = 0
42
+
43
+ def start(self) -> None:
44
+ """Start the daemon worker thread."""
45
+ self._thread.start()
46
+
47
+ def submit_batch(self, batch: list[TestResultPayload]) -> None:
48
+ """Enqueue a batch for flushing. Non-blocking.
49
+
50
+ Does nothing if the worker is disabled (circuit breaker tripped).
51
+
52
+ Args:
53
+ batch: List of TestResultPayload items to flush.
54
+ """
55
+ with self._lock:
56
+ if not self._disabled:
57
+ self._queue.put(batch)
58
+
59
+ def stop(self, timeout: float = 30.0) -> None:
60
+ """Signal stop and wait for drain up to timeout seconds.
61
+
62
+ Sends a sentinel (None) to the queue and joins the worker thread.
63
+
64
+ Args:
65
+ timeout: Maximum seconds to wait for the thread to finish.
66
+ """
67
+ self._queue.put(None) # Sentinel
68
+ self._thread.join(timeout=timeout)
69
+
70
+ @property
71
+ def dropped_count(self) -> int:
72
+ """Number of test results discarded due to flush failures."""
73
+ return self._dropped_count
74
+
75
+ def _run(self) -> None:
76
+ """Worker loop: dequeue batches, flush, handle failures."""
77
+ try:
78
+ while True:
79
+ try:
80
+ batch = self._queue.get(timeout=1.0)
81
+ except queue.Empty:
82
+ continue
83
+
84
+ if batch is None:
85
+ # Sentinel received — process remaining items then exit
86
+ self._drain_remaining()
87
+ break
88
+
89
+ if self._disabled:
90
+ break
91
+
92
+ self._flush_with_retry(batch)
93
+
94
+ if self._disabled:
95
+ break
96
+ except Exception:
97
+ logger.warning("pytest-charisma: worker thread crashed", exc_info=True)
98
+ finally:
99
+ self._client.close()
100
+
101
+ def _drain_remaining(self) -> None:
102
+ """Process any remaining batches in the queue after sentinel."""
103
+ while True:
104
+ try:
105
+ batch = self._queue.get_nowait()
106
+ except queue.Empty:
107
+ break
108
+ if batch is None:
109
+ break
110
+ if self._disabled:
111
+ break
112
+ self._flush_with_retry(batch)
113
+
114
+ def _flush_with_retry(self, batch: list[TestResultPayload]) -> None:
115
+ """Flush a batch with one retry on failure.
116
+
117
+ On success: resets consecutive failure counter.
118
+ On double failure: discards batch, increments counter, checks circuit breaker.
119
+
120
+ Args:
121
+ batch: List of TestResultPayload items to flush.
122
+ """
123
+ results = [result.to_dict() for result in batch]
124
+
125
+ success = self._client.flush_batch(self._launch_id, results)
126
+ if success:
127
+ self._consecutive_failures = 0
128
+ logger.info("pytest-charisma: flushed %d results successfully", len(results))
129
+ return
130
+
131
+ # Retry once
132
+ success = self._client.flush_batch(self._launch_id, results)
133
+ if success:
134
+ self._consecutive_failures = 0
135
+ return
136
+
137
+ # Both attempts failed — discard batch
138
+ self._dropped_count += len(batch)
139
+ self._consecutive_failures += 1
140
+ logger.warning(
141
+ "pytest-charisma: discarded %d results after retry failure "
142
+ "(consecutive failures: %d)",
143
+ len(batch),
144
+ self._consecutive_failures,
145
+ )
146
+
147
+ if self._consecutive_failures >= self.MAX_CONSECUTIVE_FAILURES:
148
+ with self._lock:
149
+ self._disabled = True
150
+ logger.warning(
151
+ "pytest-charisma: circuit breaker tripped after %d consecutive failures, "
152
+ "streaming disabled",
153
+ self._consecutive_failures,
154
+ )
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.5
2
+ Name: pytest-charisma
3
+ Version: 0.3.1
4
+ Summary: A pytest plugin that streams test results to the Charisma ingestion API as tests execute.
5
+ Author: Charisma Team
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Framework :: Pytest
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Software Development :: Testing
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: httpx<1.0,>=0.24
18
+ Requires-Dist: pytest<10.0,>=7.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: hypothesis; extra == 'dev'
21
+ Requires-Dist: pytest-xdist; extra == 'dev'
22
+ Requires-Dist: respx; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # pytest-charisma
26
+
27
+ A pytest plugin that streams test results to the Charisma ingestion API as tests execute.
28
+
29
+ Results are batched and sent in a background thread so test execution is not blocked by network I/O. The plugin implements retry-once semantics and a circuit breaker (3 consecutive failures disables streaming for the rest of the session).
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install pytest-charisma
35
+ ```
36
+
37
+ No token, no index URL — works on any laptop and in any CI. The package is published to public PyPI; the Charisma repo itself stays private (only this client plugin wheel is public).
38
+
39
+ For local development from a checkout of the repo:
40
+
41
+ ```bash
42
+ pip install -e packages/pytest-charisma
43
+ ```
44
+
45
+ ## Publishing (maintainers)
46
+
47
+ Releases publish to public PyPI automatically from the merge-queue workflow using a **PyPI API token**.
48
+
49
+ > Trusted publishing (OIDC) is not used because this repo runs on **GitHub Enterprise Server**, whose OIDC issuer PyPI does not trust. A stored API token is required instead.
50
+
51
+ One-time setup (done once by a PyPI project owner):
52
+
53
+ 1. Sign in to [PyPI](https://pypi.org) with the team-owned account (2FA enabled).
54
+ 2. Go to **Account settings → API tokens → Add API token**. Scope it to the `pytest-charisma` project once the project exists; for the very first publish use an account-scoped token, then re-scope it to the project afterward.
55
+ 3. Copy the token (starts with `pypi-`).
56
+ 4. In the GitHub Enterprise repo, add it as an Actions secret named **`PYPI_API_TOKEN`** (org-level secret recommended so both packages share it).
57
+ 5. Bump `version` in `pyproject.toml` and merge — the workflow builds and publishes on the next release.
58
+
59
+ Rotate the token periodically and store it in the team secret manager.
60
+
61
+ ---
62
+
63
+ ## Quick start
64
+
65
+ ```bash
66
+ pytest \
67
+ --charisma-url https://api.charisma.example.com \
68
+ --charisma-token $CHARISMA_TOKEN \
69
+ --charisma-project-alias my-project
70
+ ```
71
+
72
+ The plugin activates automatically when `charisma_url` is configured. If the URL is absent, the plugin stays silent and does nothing.
73
+
74
+ ## Configuration
75
+
76
+ Options are resolved with priority: **CLI flag > pytest.ini / pyproject.toml > environment variable**.
77
+
78
+ | CLI flag | ini key | Env var | Required | Description |
79
+ | -------------------------- | ------------------------ | ------------------------ | -------- | ------------------------------------- |
80
+ | `--charisma-url` | `charisma_url` | `CHARISMA_URL` | Yes | API base URL |
81
+ | `--charisma-token` | `charisma_token` | `CHARISMA_TOKEN` | Yes | Bearer token for authentication |
82
+ | `--charisma-project-alias` | `charisma_project_alias` | `CHARISMA_PROJECT_ALIAS` | Yes | Project alias in Charisma |
83
+ | `--charisma-batch-size` | `charisma_batch_size` | `CHARISMA_BATCH_SIZE` | No | Results per batch, 1–50 (default: 20) |
84
+ | `--charisma-build-id` | `charisma_build_id` | `CHARISMA_BUILD_ID` | No | CI build number or identifier |
85
+ | `--charisma-commit-sha` | `charisma_commit_sha` | `CHARISMA_COMMIT_SHA` | No | Git commit SHA |
86
+ | `--charisma-branch` | `charisma_branch` | `CHARISMA_BRANCH` | No | Git branch name |
87
+ | `--charisma-component` | `charisma_component` | `CHARISMA_COMPONENT` | No | Component alias within the project |
88
+ | `--charisma-source-url` | `charisma_source_url` | `CHARISMA_SOURCE_URL` | No | Link back to CI/CD run |
89
+
90
+ ## Configuration via pyproject.toml
91
+
92
+ ```toml
93
+ [tool.pytest.ini_options]
94
+ charisma_url = "https://api.charisma.example.com"
95
+ charisma_project_alias = "my-project"
96
+ charisma_batch_size = "10"
97
+ charisma_branch = "main"
98
+ # Token should come from env var for security:
99
+ # export CHARISMA_TOKEN=your-token
100
+ ```
101
+
102
+ ## Configuration via pytest.ini
103
+
104
+ ```ini
105
+ [pytest]
106
+ charisma_url = https://api.charisma.example.com
107
+ charisma_project_alias = my-project
108
+ charisma_batch_size = 10
109
+ ```
110
+
111
+ ## Environment variables only (CI-friendly)
112
+
113
+ ```bash
114
+ export CHARISMA_URL=https://api.charisma.example.com
115
+ export CHARISMA_TOKEN=your-api-token
116
+ export CHARISMA_PROJECT_ALIAS=my-project
117
+ export CHARISMA_BUILD_ID=$CI_BUILD_NUMBER
118
+ export CHARISMA_COMMIT_SHA=$CI_COMMIT_SHA
119
+ export CHARISMA_BRANCH=$CI_BRANCH
120
+ export CHARISMA_SOURCE_URL=$CI_BUILD_URL
121
+
122
+ pytest
123
+ ```
124
+
125
+ ## How it works
126
+
127
+ 1. **Collection** — After pytest collects tests, the plugin opens a launch session via `POST /api/v1/launches`.
128
+ 2. **Execution** — As each test completes, results are buffered. When the buffer reaches `batch_size`, the batch is submitted to a background worker thread.
129
+ 3. **Flush** — At session end, remaining results are flushed and the worker drains its queue (up to 30s timeout).
130
+ 4. **Resilience** — Each batch gets one retry on failure. After 3 consecutive batch failures, the circuit breaker trips and streaming is disabled for the rest of the session. Test execution is never affected.
131
+
132
+ ## Outcome mapping
133
+
134
+ | pytest outcome | Charisma status |
135
+ | ------------------------- | --------------- |
136
+ | `passed` (call phase) | `passed` |
137
+ | `failed` (call phase) | `failed` |
138
+ | `skipped` | `skipped` |
139
+ | `failed` (setup/teardown) | `broken` |
140
+
141
+ ## Requirements
142
+
143
+ - Python ≥ 3.10
144
+ - pytest ≥ 7.0, < 9.0
145
+ - httpx ≥ 0.24, < 1.0
146
+
147
+ ## Development
148
+
149
+ ```bash
150
+ cd packages/pytest-charisma
151
+ pip install -e ".[dev]"
152
+ pytest
153
+ ```
154
+
155
+ ## License
156
+
157
+ MIT
@@ -0,0 +1,10 @@
1
+ pytest_charisma/__init__.py,sha256=ek63G-CfvPcr5gwZT6FD7FQkKYZDj4Zzrfqr9K43bnM,119
2
+ pytest_charisma/client.py,sha256=xuCA0rSMhK_RYH4_gk_XnjKrrfhzMZeR7Ir8zNaaU9Y,4349
3
+ pytest_charisma/config.py,sha256=yQdeZwIC5AaUyRR8OZ10FmMKiKeedVV61of0UBFQLcc,8830
4
+ pytest_charisma/models.py,sha256=KdnGD_m9wbaC8dU-7FGchV2zD2Wq3DF7QcWIV9wwcYs,4246
5
+ pytest_charisma/plugin.py,sha256=0Mj07OAdwV3lejTC23hUg0QvNT5Wa_sTrUveZwPbpNM,21260
6
+ pytest_charisma/worker.py,sha256=O2EyKZrJxacHgYAJMPZDb4y2NadKU9U96TZkmh5C4iM,5076
7
+ pytest_charisma-0.3.1.dist-info/METADATA,sha256=MxOCkMzWzNU19aLH8xqGUE51XM26qennllfwQJQ37UI,6502
8
+ pytest_charisma-0.3.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
9
+ pytest_charisma-0.3.1.dist-info/entry_points.txt,sha256=RWB7HLn1ohGG-50fppgwicuRqHj8iVLJ9gkpajsN62k,45
10
+ pytest_charisma-0.3.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ charisma = pytest_charisma.plugin