charisma-cli 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- charisma_cli/__init__.py +3 -0
- charisma_cli/config.py +86 -0
- charisma_cli/main.py +206 -0
- charisma_cli/models.py +76 -0
- charisma_cli/parser.py +175 -0
- charisma_cli/retry.py +18 -0
- charisma_cli/subprocess_mgr.py +63 -0
- charisma_cli/uploader.py +488 -0
- charisma_cli/watcher.py +223 -0
- charisma_cli-0.1.2.dist-info/METADATA +98 -0
- charisma_cli-0.1.2.dist-info/RECORD +13 -0
- charisma_cli-0.1.2.dist-info/WHEEL +4 -0
- charisma_cli-0.1.2.dist-info/entry_points.txt +2 -0
charisma_cli/watcher.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Watchdog observer, stability debounce, and file classification for allure-results."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from queue import PriorityQueue
|
|
6
|
+
from threading import Timer
|
|
7
|
+
|
|
8
|
+
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
9
|
+
from watchdog.observers import Observer
|
|
10
|
+
|
|
11
|
+
from charisma_cli.config import Config
|
|
12
|
+
from charisma_cli.models import FileCategory, FileEvent
|
|
13
|
+
|
|
14
|
+
_TWO_MB = 2 * 1024 * 1024
|
|
15
|
+
_STABILITY_SECONDS = 0.5
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def classify_file(filename: str) -> FileCategory | None:
|
|
19
|
+
"""Classify a filename into a FileCategory or None if excluded.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
filename: The base filename (not full path).
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
FileCategory.RESULT for *-result.json,
|
|
26
|
+
FileCategory.CONTAINER for *-container.json,
|
|
27
|
+
FileCategory.ATTACHMENT for all other valid files,
|
|
28
|
+
None for excluded files (dot-prefix or .tmp suffix).
|
|
29
|
+
"""
|
|
30
|
+
if filename.startswith("."):
|
|
31
|
+
return None
|
|
32
|
+
if filename.endswith(".tmp"):
|
|
33
|
+
return None
|
|
34
|
+
if filename.endswith("-result.json"):
|
|
35
|
+
return FileCategory.RESULT
|
|
36
|
+
if filename.endswith("-container.json"):
|
|
37
|
+
return FileCategory.CONTAINER
|
|
38
|
+
return FileCategory.ATTACHMENT
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def should_skip(path: Path, config: Config) -> bool:
|
|
42
|
+
"""Check whether a file should be skipped based on size.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
path: Path to the file on disk.
|
|
46
|
+
config: Resolved CLI configuration.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
True if the file exceeds 2MB and skip_too_big is enabled.
|
|
50
|
+
"""
|
|
51
|
+
if not config.skip_too_big:
|
|
52
|
+
return False
|
|
53
|
+
return path.stat().st_size > _TWO_MB
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _StabilityHandler(FileSystemEventHandler):
|
|
57
|
+
"""Debounces filesystem events, enqueuing files after 500ms of stability."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, queue: PriorityQueue, config: Config) -> None:
|
|
60
|
+
super().__init__()
|
|
61
|
+
self._queue = queue
|
|
62
|
+
self._config = config
|
|
63
|
+
self._timers: dict[str, Timer] = {}
|
|
64
|
+
|
|
65
|
+
def on_created(self, event: FileSystemEvent) -> None:
|
|
66
|
+
"""Handle file creation events."""
|
|
67
|
+
if not event.is_directory:
|
|
68
|
+
self._handle_event(event.src_path)
|
|
69
|
+
|
|
70
|
+
def on_modified(self, event: FileSystemEvent) -> None:
|
|
71
|
+
"""Handle file modification events."""
|
|
72
|
+
if not event.is_directory:
|
|
73
|
+
self._handle_event(event.src_path)
|
|
74
|
+
|
|
75
|
+
def _handle_event(self, src_path: str) -> None:
|
|
76
|
+
"""Reset the stability timer for a file path."""
|
|
77
|
+
path = Path(src_path)
|
|
78
|
+
filename = path.name
|
|
79
|
+
|
|
80
|
+
category = classify_file(filename)
|
|
81
|
+
if category is None:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
# Cancel existing timer for this path
|
|
85
|
+
existing = self._timers.get(src_path)
|
|
86
|
+
if existing is not None:
|
|
87
|
+
existing.cancel()
|
|
88
|
+
|
|
89
|
+
# Set a new timer that enqueues after stability period
|
|
90
|
+
timer = Timer(_STABILITY_SECONDS, self._enqueue, args=(path, category))
|
|
91
|
+
timer.daemon = True
|
|
92
|
+
self._timers[src_path] = timer
|
|
93
|
+
timer.start()
|
|
94
|
+
|
|
95
|
+
def _enqueue(self, path: Path, category: FileCategory) -> None:
|
|
96
|
+
"""Enqueue a file event after stability check passes."""
|
|
97
|
+
# Remove from timer dict
|
|
98
|
+
self._timers.pop(str(path), None)
|
|
99
|
+
|
|
100
|
+
# Check file still exists and size at enqueue time
|
|
101
|
+
try:
|
|
102
|
+
if should_skip(path, self._config):
|
|
103
|
+
return
|
|
104
|
+
except (OSError, FileNotFoundError):
|
|
105
|
+
return # File was deleted between debounce and enqueue
|
|
106
|
+
|
|
107
|
+
self._queue.put(FileEvent(path=path, category=category))
|
|
108
|
+
|
|
109
|
+
def cancel_all(self) -> None:
|
|
110
|
+
"""Cancel all pending timers."""
|
|
111
|
+
for timer in self._timers.values():
|
|
112
|
+
timer.cancel()
|
|
113
|
+
self._timers.clear()
|
|
114
|
+
|
|
115
|
+
def flush_pending(self) -> None:
|
|
116
|
+
"""Immediately enqueue all files currently in debounce windows.
|
|
117
|
+
|
|
118
|
+
Called after subprocess exit to ensure no result files are lost due
|
|
119
|
+
to pending debounce timers. Cancels all timers and enqueues their
|
|
120
|
+
associated files directly.
|
|
121
|
+
"""
|
|
122
|
+
# Snapshot and clear timers atomically
|
|
123
|
+
pending = dict(self._timers)
|
|
124
|
+
self._timers.clear()
|
|
125
|
+
|
|
126
|
+
for src_path, timer in pending.items():
|
|
127
|
+
timer.cancel()
|
|
128
|
+
path = Path(src_path)
|
|
129
|
+
filename = path.name
|
|
130
|
+
|
|
131
|
+
category = classify_file(filename)
|
|
132
|
+
if category is None:
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
if should_skip(path, self._config):
|
|
137
|
+
continue
|
|
138
|
+
except (OSError, FileNotFoundError):
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
self._queue.put(FileEvent(path=path, category=category))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ResultsWatcher:
|
|
145
|
+
"""Watches the allure-results directory for new files using watchdog.
|
|
146
|
+
|
|
147
|
+
Creates the directory if it doesn't exist, observes file creation/modification,
|
|
148
|
+
debounces events for 500ms of stability, then enqueues FileEvent objects
|
|
149
|
+
into the provided PriorityQueue.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def __init__(self, results_dir: str, queue: PriorityQueue, config: Config) -> None:
|
|
153
|
+
self._results_dir = results_dir
|
|
154
|
+
self._queue = queue
|
|
155
|
+
self._config = config
|
|
156
|
+
self._handler = _StabilityHandler(queue, config)
|
|
157
|
+
self._observer = Observer()
|
|
158
|
+
|
|
159
|
+
def start(self) -> None:
|
|
160
|
+
"""Create the results directory and start observing for file events."""
|
|
161
|
+
os.makedirs(self._results_dir, exist_ok=True)
|
|
162
|
+
self._observer.schedule(self._handler, self._results_dir, recursive=False)
|
|
163
|
+
self._observer.start()
|
|
164
|
+
|
|
165
|
+
def stop(self) -> None:
|
|
166
|
+
"""Stop the observer and cancel all pending stability timers."""
|
|
167
|
+
self._observer.stop()
|
|
168
|
+
self._observer.join()
|
|
169
|
+
self._handler.cancel_all()
|
|
170
|
+
|
|
171
|
+
def flush_pending(self) -> None:
|
|
172
|
+
"""Flush all files currently in debounce windows into the queue.
|
|
173
|
+
|
|
174
|
+
Should be called BEFORE stop() when you want to capture all pending
|
|
175
|
+
files rather than discard them.
|
|
176
|
+
"""
|
|
177
|
+
self._handler.flush_pending()
|
|
178
|
+
|
|
179
|
+
def final_scan(self, already_seen: set[str] | None = None) -> int:
|
|
180
|
+
"""Scan the results directory and enqueue any files not already processed.
|
|
181
|
+
|
|
182
|
+
Performs a one-time sweep of all files in the results directory,
|
|
183
|
+
enqueuing any that pass classification and size checks. This catches
|
|
184
|
+
files that the filesystem watcher may have missed entirely (e.g., written
|
|
185
|
+
between watcher setup and observation start, or during high-throughput
|
|
186
|
+
bursts that overwhelm OS event buffers).
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
already_seen: Optional set of absolute path strings that were
|
|
190
|
+
already enqueued. Files in this set are skipped.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Number of new files enqueued by this scan.
|
|
194
|
+
"""
|
|
195
|
+
results_path = Path(self._results_dir)
|
|
196
|
+
if not results_path.exists():
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
enqueued = 0
|
|
200
|
+
seen = already_seen or set()
|
|
201
|
+
|
|
202
|
+
for filepath in results_path.iterdir():
|
|
203
|
+
if not filepath.is_file():
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
abs_path = str(filepath.resolve())
|
|
207
|
+
if abs_path in seen:
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
category = classify_file(filepath.name)
|
|
211
|
+
if category is None:
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
if should_skip(filepath, self._config):
|
|
216
|
+
continue
|
|
217
|
+
except (OSError, FileNotFoundError):
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
self._queue.put(FileEvent(path=filepath, category=category))
|
|
221
|
+
enqueued += 1
|
|
222
|
+
|
|
223
|
+
return enqueued
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: charisma-cli
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: CLI tool that watches allure-results and streams test results + attachments to Charisma.
|
|
5
|
+
Author: Charisma Team
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
8
|
+
Classifier: Environment :: Console
|
|
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: click<9.0,>=8.0
|
|
18
|
+
Requires-Dist: httpx<1.0,>=0.24
|
|
19
|
+
Requires-Dist: watchdog<5.0,>=3.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: hypothesis; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest<10.0,>=7.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: respx; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# charisma-cli
|
|
27
|
+
|
|
28
|
+
CLI tool that watches an `allure-results` directory during test execution and streams results + attachments to the Charisma ingestion API in real time.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install charisma-cli
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
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 wheel is public).
|
|
37
|
+
|
|
38
|
+
For local development from a checkout of the repo:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install -e packages/charisma-cli
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
<details>
|
|
45
|
+
<summary>Alternative: install from the GitHub Packages registry (PyOCI)</summary>
|
|
46
|
+
|
|
47
|
+
Also published to GitHub Packages via [PyOCI](https://pyoci.com) (requires a GitHub token with `read:packages`) — useful for a build not yet on PyPI:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install charisma-cli \
|
|
51
|
+
--index-url "https://__token__:$GITHUB_TOKEN@pyoci.com/ghcr.io/align-QCOE/"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
</details>
|
|
55
|
+
|
|
56
|
+
## Publishing (maintainers)
|
|
57
|
+
|
|
58
|
+
Releases publish to public PyPI automatically from the merge-queue workflow using a **PyPI API token**.
|
|
59
|
+
|
|
60
|
+
> 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.
|
|
61
|
+
|
|
62
|
+
One-time setup (done once by a PyPI project owner):
|
|
63
|
+
|
|
64
|
+
1. Sign in to [PyPI](https://pypi.org) with the team-owned account (2FA enabled).
|
|
65
|
+
2. Go to **Account settings → API tokens → Add API token**. Scope it to the `charisma-cli` project once the project exists; for the very first publish use an account-scoped token, then re-scope it to the project afterward.
|
|
66
|
+
3. Copy the token (starts with `pypi-`).
|
|
67
|
+
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).
|
|
68
|
+
5. Bump `version` in `pyproject.toml` and merge — the workflow builds and publishes on the next release.
|
|
69
|
+
|
|
70
|
+
Rotate the token periodically and store it in the team secret manager.
|
|
71
|
+
|
|
72
|
+
## Usage
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
export CHARISMA_ENDPOINT=https://charisma.company.com
|
|
76
|
+
export CHARISMA_TOKEN=<your-service-token>
|
|
77
|
+
export CHARISMA_PROJECT_ID=my-project
|
|
78
|
+
|
|
79
|
+
charismactl watch --results allure-results -- uv run pytest -n 4 tests/
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Environment Variables
|
|
83
|
+
|
|
84
|
+
| Variable | Required | Description |
|
|
85
|
+
| --------------------- | -------- | -------------------------------------------------- |
|
|
86
|
+
| `CHARISMA_ENDPOINT` | Yes | Base URL of the Charisma API |
|
|
87
|
+
| `CHARISMA_TOKEN` | Yes | Service or CI bearer token |
|
|
88
|
+
| `CHARISMA_PROJECT_ID` | Yes | Project alias for result routing |
|
|
89
|
+
| `CHARISMA_RESULTS` | No | Path to allure-results (default: `allure-results`) |
|
|
90
|
+
|
|
91
|
+
## Flags
|
|
92
|
+
|
|
93
|
+
| Flag | Description |
|
|
94
|
+
| ---------------- | -------------------------------------------- |
|
|
95
|
+
| `--results PATH` | Allure results directory (overrides env var) |
|
|
96
|
+
| `--project ID` | Project alias (overrides env var) |
|
|
97
|
+
| `--silent` | Don't fail pipeline if upload errors occur |
|
|
98
|
+
| `--skip-too-big` | Skip result files larger than 2MB |
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
charisma_cli/__init__.py,sha256=cA_-La2crYdATs-1IgSAd1gCUH7-C1Zy5wIPiMFhQag,91
|
|
2
|
+
charisma_cli/config.py,sha256=hEBIcteuwLDPTRRTtRvFcXsuY4lECXlZUgXGGHkH5VE,2649
|
|
3
|
+
charisma_cli/main.py,sha256=AkSV7DXo80kot0yeNjj3hFHg331OO9z4I9Zo4FT6lKw,6812
|
|
4
|
+
charisma_cli/models.py,sha256=w3_CsTl53KO02OH2V5BwxatxJj2ucTvMh1gCN_ScwH4,2148
|
|
5
|
+
charisma_cli/parser.py,sha256=kMOBKtGO269G-v-Ypeq6vVN1XlZ28veM1KCPHxvKmEI,5083
|
|
6
|
+
charisma_cli/retry.py,sha256=9bwza5kT1tOCJoGEGG_e5ZGnnDNa5oDw7XtlGsdmY-4,595
|
|
7
|
+
charisma_cli/subprocess_mgr.py,sha256=0pq30J7owbqMUAa_Py9HUzA9KNvVh_5y4scH2N1tHs4,1994
|
|
8
|
+
charisma_cli/uploader.py,sha256=sZjKXU9WEuuPOOl3Rj8mNsij2nYZ3jfbr7A8HoyuKyQ,19156
|
|
9
|
+
charisma_cli/watcher.py,sha256=a7m5Yf_7gYx0Z7G275N33dYBbr3R0cXiMBeRwUviz4w,7436
|
|
10
|
+
charisma_cli-0.1.2.dist-info/METADATA,sha256=VEJZQtRd2YPKeFO94g91-MCaOWuNVEkGrtxQdf27jUQ,4079
|
|
11
|
+
charisma_cli-0.1.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
12
|
+
charisma_cli-0.1.2.dist-info/entry_points.txt,sha256=O41ZogN9NRFfnEZNHIM1C72-jhlcFnRWqC1D8zZTby0,54
|
|
13
|
+
charisma_cli-0.1.2.dist-info/RECORD,,
|