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.
- pytest_charisma/__init__.py +3 -0
- pytest_charisma/client.py +121 -0
- pytest_charisma/config.py +265 -0
- pytest_charisma/models.py +120 -0
- pytest_charisma/plugin.py +536 -0
- pytest_charisma/worker.py +154 -0
- pytest_charisma-0.3.1.dist-info/METADATA +157 -0
- pytest_charisma-0.3.1.dist-info/RECORD +10 -0
- pytest_charisma-0.3.1.dist-info/WHEEL +4 -0
- pytest_charisma-0.3.1.dist-info/entry_points.txt +2 -0
|
@@ -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,,
|