charisma-cli 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,,
@@ -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
+ [console_scripts]
2
+ charismactl = charisma_cli.main:cli