charisma-cli 0.1.2__tar.gz → 0.1.4__tar.gz

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.
Files changed (27) hide show
  1. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/PKG-INFO +20 -13
  2. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/README.md +19 -12
  3. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/pyproject.toml +1 -1
  4. charisma_cli-0.1.4/src/charisma_cli/launch_url.py +45 -0
  5. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/src/charisma_cli/main.py +47 -0
  6. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/src/charisma_cli/uploader.py +58 -12
  7. charisma_cli-0.1.4/tests/test_launch_url.py +37 -0
  8. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/test_uploader.py +49 -0
  9. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/.gitignore +0 -0
  10. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/src/charisma_cli/__init__.py +0 -0
  11. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/src/charisma_cli/config.py +0 -0
  12. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/src/charisma_cli/models.py +0 -0
  13. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/src/charisma_cli/parser.py +0 -0
  14. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/src/charisma_cli/retry.py +0 -0
  15. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/src/charisma_cli/subprocess_mgr.py +0 -0
  16. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/src/charisma_cli/watcher.py +0 -0
  17. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/__init__.py +0 -0
  18. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/conftest.py +0 -0
  19. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/test_cli.py +0 -0
  20. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/test_config.py +0 -0
  21. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/test_integration.py +0 -0
  22. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/test_models.py +0 -0
  23. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/test_parser.py +0 -0
  24. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/test_retry.py +0 -0
  25. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/test_silent_mode.py +0 -0
  26. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/test_subprocess_mgr.py +0 -0
  27. {charisma_cli-0.1.2 → charisma_cli-0.1.4}/tests/test_watcher.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: charisma-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: CLI tool that watches allure-results and streams test results + attachments to Charisma.
5
5
  Author: Charisma Team
6
6
  License-Expression: MIT
@@ -41,18 +41,6 @@ For local development from a checkout of the repo:
41
41
  pip install -e packages/charisma-cli
42
42
  ```
43
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
44
  ## Publishing (maintainers)
57
45
 
58
46
  Releases publish to public PyPI automatically from the merge-queue workflow using a **PyPI API token**.
@@ -96,3 +84,22 @@ charismactl watch --results allure-results -- uv run pytest -n 4 tests/
96
84
  | `--project ID` | Project alias (overrides env var) |
97
85
  | `--silent` | Don't fail pipeline if upload errors occur |
98
86
  | `--skip-too-big` | Skip result files larger than 2MB |
87
+
88
+ ## Launch URL in CI
89
+
90
+ `charismactl` prints the Charisma launch URL (`Charisma launch: <url>`) and, when running
91
+ in GitHub Actions, writes it as a step output named `launch_url`. Attach it to the job
92
+ summary or a Teams message:
93
+
94
+ ```yaml
95
+ - name: Run tests and stream to Charisma
96
+ id: charisma
97
+ run: charismactl watch --project my-project -- pytest tests/
98
+
99
+ - name: Add Charisma link to summary
100
+ if: always()
101
+ run: echo "[View Charisma launch](${{ steps.charisma.outputs.launch_url }})" >> "$GITHUB_STEP_SUMMARY"
102
+
103
+ # The same value can feed a Teams-notification step:
104
+ # text: "Results: ${{ steps.charisma.outputs.launch_url }}"
105
+ ```
@@ -16,18 +16,6 @@ For local development from a checkout of the repo:
16
16
  pip install -e packages/charisma-cli
17
17
  ```
18
18
 
19
- <details>
20
- <summary>Alternative: install from the GitHub Packages registry (PyOCI)</summary>
21
-
22
- 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:
23
-
24
- ```bash
25
- pip install charisma-cli \
26
- --index-url "https://__token__:$GITHUB_TOKEN@pyoci.com/ghcr.io/align-QCOE/"
27
- ```
28
-
29
- </details>
30
-
31
19
  ## Publishing (maintainers)
32
20
 
33
21
  Releases publish to public PyPI automatically from the merge-queue workflow using a **PyPI API token**.
@@ -71,3 +59,22 @@ charismactl watch --results allure-results -- uv run pytest -n 4 tests/
71
59
  | `--project ID` | Project alias (overrides env var) |
72
60
  | `--silent` | Don't fail pipeline if upload errors occur |
73
61
  | `--skip-too-big` | Skip result files larger than 2MB |
62
+
63
+ ## Launch URL in CI
64
+
65
+ `charismactl` prints the Charisma launch URL (`Charisma launch: <url>`) and, when running
66
+ in GitHub Actions, writes it as a step output named `launch_url`. Attach it to the job
67
+ summary or a Teams message:
68
+
69
+ ```yaml
70
+ - name: Run tests and stream to Charisma
71
+ id: charisma
72
+ run: charismactl watch --project my-project -- pytest tests/
73
+
74
+ - name: Add Charisma link to summary
75
+ if: always()
76
+ run: echo "[View Charisma launch](${{ steps.charisma.outputs.launch_url }})" >> "$GITHUB_STEP_SUMMARY"
77
+
78
+ # The same value can feed a Teams-notification step:
79
+ # text: "Results: ${{ steps.charisma.outputs.launch_url }}"
80
+ ```
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
 
6
6
  [project]
7
7
  name = "charisma-cli"
8
- version = "0.1.2"
8
+ version = "0.1.4"
9
9
  description = "CLI tool that watches allure-results and streams test results + attachments to Charisma."
10
10
  readme = "README.md"
11
11
  license = "MIT"
@@ -0,0 +1,45 @@
1
+ """Construct and surface the Charisma launch URL for CI consumption."""
2
+
3
+ import logging
4
+ import os
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ # The Charisma web UI base is always the same, unlike the region-dependent API
9
+ # ingestion endpoint. Hardcoded here rather than exposed as user config.
10
+ WEB_BASE_URL = "https://charisma.aligntech.com"
11
+
12
+
13
+ def build_launch_url(project_id: str, launch_id: str) -> str:
14
+ """Build the Charisma web URL for a launch.
15
+
16
+ Mirrors the frontend route ``/project/{projectId}/launches/{launchId}``.
17
+
18
+ Args:
19
+ project_id: Project UUID (from the open-launch API response).
20
+ launch_id: Launch UUID.
21
+
22
+ Returns:
23
+ The fully-qualified launch URL.
24
+ """
25
+ return f"{WEB_BASE_URL}/project/{project_id}/launches/{launch_id}"
26
+
27
+
28
+ def emit_github_output(launch_url: str) -> None:
29
+ """Append ``launch_url=<url>`` to the GitHub Actions step output file.
30
+
31
+ No-op when not running under GitHub Actions (``GITHUB_OUTPUT`` unset).
32
+ Failures are logged and swallowed — surfacing the URL must never affect
33
+ the exit code or upload outcome.
34
+
35
+ Args:
36
+ launch_url: The launch URL to expose as the ``launch_url`` step output.
37
+ """
38
+ github_output = os.getenv("GITHUB_OUTPUT")
39
+ if not github_output:
40
+ return
41
+ try:
42
+ with open(github_output, "a", encoding="utf-8") as fh:
43
+ fh.write(f"launch_url={launch_url}\n")
44
+ except OSError as e:
45
+ logger.warning("Could not write launch_url to GITHUB_OUTPUT: %s", e)
@@ -9,6 +9,7 @@ import click
9
9
 
10
10
  from charisma_cli import __version__
11
11
  from charisma_cli.config import Config
12
+ from charisma_cli.launch_url import build_launch_url, emit_github_output
12
13
  from charisma_cli.models import FileEvent
13
14
  from charisma_cli.subprocess_mgr import SubprocessManager
14
15
  from charisma_cli.uploader import Uploader
@@ -59,6 +60,45 @@ def _open_launch_or_exit(uploader: Uploader, config: Config) -> str:
59
60
  return launch_id
60
61
 
61
62
 
63
+ def _surface_launch_url(uploader: Uploader) -> None:
64
+ """Print the Charisma launch URL and expose it to GitHub Actions.
65
+
66
+ Constructs the URL from the project UUID returned by the open-launch
67
+ response and the launch ID. Prints it to stdout and, when running under
68
+ GitHub Actions, appends ``launch_url=<url>`` to $GITHUB_OUTPUT.
69
+ Does nothing if no launch was opened or the project UUID is unavailable.
70
+ """
71
+ launch_id = uploader.launch_id
72
+ project_id = uploader.project_id
73
+ if not launch_id or not project_id:
74
+ return
75
+ url = build_launch_url(project_id, launch_id)
76
+ click.echo(f"Charisma launch: {url}")
77
+ emit_github_output(url)
78
+
79
+
80
+ def _warn_if_results_unsent(uploader: Uploader, config: Config) -> None:
81
+ """Warn loudly when result files exist on disk but none were sent.
82
+
83
+ Turns the previously-silent ``results_sent=0`` failure into a visible
84
+ warning naming the absolute watched path and the on-disk result count, so
85
+ a broken run is not mistaken for success.
86
+ """
87
+ if uploader.summary.results_sent > 0:
88
+ return
89
+ results_path = Path(config.results_dir).resolve()
90
+ if not results_path.exists():
91
+ return
92
+ result_files = [p for p in results_path.iterdir() if p.name.endswith("-result.json")]
93
+ if result_files:
94
+ click.echo(
95
+ f"Warning: {len(result_files)} result file(s) present in "
96
+ f"{results_path} but results_sent=0 — nothing was uploaded. "
97
+ "Check connectivity/auth or file another issue.",
98
+ err=True,
99
+ )
100
+
101
+
62
102
  def _print_summary(uploader: Uploader) -> None:
63
103
  """Print the upload summary to stdout."""
64
104
  summary = uploader.summary
@@ -106,6 +146,9 @@ def watch(
106
146
  mgr.spawn(command)
107
147
  sys.exit(mgr.wait())
108
148
 
149
+ # Surface the launch URL (stdout + $GITHUB_OUTPUT) now that the launch is open
150
+ _surface_launch_url(uploader)
151
+
109
152
  # Start watcher + uploader consumer
110
153
  watcher = ResultsWatcher(config.results_dir, queue, config)
111
154
  watcher.start()
@@ -146,6 +189,7 @@ def watch(
146
189
  uploader._client.close()
147
190
  uploader._client = None
148
191
 
192
+ _warn_if_results_unsent(uploader, config)
149
193
  _print_summary(uploader)
150
194
  sys.exit(exit_code)
151
195
 
@@ -181,6 +225,9 @@ def upload(
181
225
  click.echo("Warning: Failed to open launch. No results uploaded.", err=True)
182
226
  sys.exit(0)
183
227
 
228
+ # Surface the launch URL (stdout + $GITHUB_OUTPUT) now that the launch is open
229
+ _surface_launch_url(uploader)
230
+
184
231
  # Scan existing results directory
185
232
  results_path = Path(config.results_dir)
186
233
  if not results_path.exists():
@@ -51,10 +51,12 @@ class Uploader:
51
51
  self._queue = queue
52
52
  self._summary = UploadSummary()
53
53
  self._launch_id: str | None = None
54
+ self._project_id: str | None = None
54
55
  self._disabled = False
55
56
  self._auth_failed = False
56
57
  self._stop_event = threading.Event()
57
58
  self._drain_event = threading.Event()
59
+ self._batch_lock = threading.Lock()
58
60
  self._thread: threading.Thread | None = None
59
61
  self._client: httpx.Client | None = None
60
62
  self._batch: list[dict[str, Any]] = []
@@ -69,6 +71,16 @@ class Uploader:
69
71
  """Return current upload statistics."""
70
72
  return self._summary
71
73
 
74
+ @property
75
+ def launch_id(self) -> str | None:
76
+ """Return the opened launch ID, or None if no launch was opened."""
77
+ return self._launch_id
78
+
79
+ @property
80
+ def project_id(self) -> str | None:
81
+ """Return the project UUID from the open-launch response, if available."""
82
+ return self._project_id
83
+
72
84
  def configure_logging(self) -> None:
73
85
  """Configure logging based on silent mode.
74
86
 
@@ -113,17 +125,45 @@ class Uploader:
113
125
  self._client = None
114
126
 
115
127
  def drain(self, timeout: float) -> None:
116
- """Signal drain and wait for queue to empty or timeout to expire.
128
+ """Signal drain and wait until all consumed items are flushed/sent.
129
+
130
+ Waits for BOTH the queue to empty AND the in-memory batch to be flushed.
131
+ Waiting on queue emptiness alone is insufficient: the consumer moves a
132
+ result from the queue into ``self._batch`` (queue becomes empty) but only
133
+ flushes at 50 items or the 2s batch timeout. For short runs (e.g. a single
134
+ result enqueued by final_scan after the subprocess exits), that left the
135
+ result unsent — the ``watch results_sent=0`` bug. Once the queue is empty,
136
+ this forces a final batch flush so pending results are sent before return.
117
137
 
118
138
  Args:
119
- timeout: Maximum seconds to wait for the queue to drain.
139
+ timeout: Maximum seconds to wait for the queue+batch to drain.
120
140
  """
121
141
  self._drain_event.set()
122
142
  deadline = time.monotonic() + timeout
123
- while not self._queue.empty() and time.monotonic() < deadline:
124
- time.sleep(0.1)
125
- if not self._queue.empty():
126
- logger.warning("Drain timeout expired with %d items remaining in queue", self._queue.qsize())
143
+
144
+ # Phase 1: wait for the queue to empty AND for the consumer to finish
145
+ # processing every dequeued item. Checking queue.empty() alone is racy:
146
+ # an item that has been dequeued but not yet appended to the batch is in
147
+ # neither the queue nor the batch. unfinished_tasks (decremented by the
148
+ # consumer's task_done()) closes that window.
149
+ while (
150
+ (not self._queue.empty() or self._queue.unfinished_tasks > 0)
151
+ and time.monotonic() < deadline
152
+ ):
153
+ time.sleep(0.05)
154
+
155
+ # Phase 2: the consumer is idle and everything is now in the batch (or
156
+ # already sent). Force out any partial batch that hasn't hit the 50-item
157
+ # or 2s trigger, so drain() honors its contract: everything consumed has
158
+ # been sent when it returns.
159
+ self._flush_batch()
160
+
161
+ if not self._queue.empty() or self._queue.unfinished_tasks > 0 or self._batch:
162
+ logger.warning(
163
+ "Drain timeout expired with %d queued and %d batched items remaining",
164
+ self._queue.qsize(),
165
+ len(self._batch),
166
+ )
127
167
 
128
168
  def open_launch(self) -> str | None:
129
169
  """Open a streaming launch via the API.
@@ -150,6 +190,7 @@ class Uploader:
150
190
  if response.status_code == 201:
151
191
  data = response.json()
152
192
  self._launch_id = data["launchId"]
193
+ self._project_id = data.get("projectId")
153
194
  return self._launch_id
154
195
  if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
155
196
  time.sleep(exponential_backoff(attempt))
@@ -411,13 +452,18 @@ class Uploader:
411
452
  self._flush_batch()
412
453
 
413
454
  def _flush_batch(self) -> None:
414
- """Send the current batch to the API."""
415
- if not self._batch or not self._launch_id:
416
- return
455
+ """Send the current batch to the API.
417
456
 
418
- batch = self._batch[:]
419
- self._batch = []
420
- self._batch_start = 0.0
457
+ Thread-safe: the batch may be flushed by the consumer thread (on 50-item
458
+ or 2s-timeout triggers) or by drain() on the main thread. The lock ensures
459
+ the swap-and-clear is atomic so a batch is never sent twice or lost.
460
+ """
461
+ with self._batch_lock:
462
+ if not self._batch or not self._launch_id:
463
+ return
464
+ batch = self._batch[:]
465
+ self._batch = []
466
+ self._batch_start = 0.0
421
467
 
422
468
  success = self._send_batch(batch)
423
469
  if success:
@@ -0,0 +1,37 @@
1
+ """Unit tests for charisma_cli.launch_url: URL construction + GitHub output emission."""
2
+
3
+ from pathlib import Path
4
+
5
+ from charisma_cli.launch_url import build_launch_url, emit_github_output
6
+
7
+
8
+ class TestBuildLaunchUrl:
9
+ """build_launch_url mirrors the frontend /project/{id}/launches/{id} route."""
10
+
11
+ def test_basic_url(self) -> None:
12
+ url = build_launch_url("proj-uuid", "launch-uuid")
13
+ assert url == "https://charisma.aligntech.com/project/proj-uuid/launches/launch-uuid"
14
+
15
+
16
+ class TestEmitGithubOutput:
17
+ """emit_github_output appends launch_url only when GITHUB_OUTPUT is set."""
18
+
19
+ def test_writes_when_github_output_set(self, tmp_path: Path, monkeypatch) -> None:
20
+ out = tmp_path / "gh_output"
21
+ out.write_text("") # simulate existing GITHUB_OUTPUT file
22
+ monkeypatch.setenv("GITHUB_OUTPUT", str(out))
23
+
24
+ emit_github_output("https://charisma.aligntech.com/project/p/launches/l")
25
+
26
+ content = out.read_text()
27
+ assert "launch_url=https://charisma.aligntech.com/project/p/launches/l\n" in content
28
+
29
+ def test_noop_when_github_output_unset(self, monkeypatch) -> None:
30
+ monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
31
+ # Should simply return without raising
32
+ emit_github_output("https://charisma.aligntech.com/project/p/launches/l")
33
+
34
+ def test_bad_path_does_not_raise(self, monkeypatch) -> None:
35
+ # Point at an unwritable/nonexistent directory — must be swallowed
36
+ monkeypatch.setenv("GITHUB_OUTPUT", "/nonexistent-dir-xyz/output")
37
+ emit_github_output("https://charisma.aligntech.com/project/p/launches/l")
@@ -282,3 +282,52 @@ class TestUploaderSummary:
282
282
  assert summary.attachments_sent == 0
283
283
  assert summary.attachments_skipped == 0
284
284
  assert summary.containers_sent == 0
285
+
286
+
287
+ class TestUploaderDrainFlushesBatch:
288
+ """Regression: drain() must flush the pending sub-batch, not just empty the queue.
289
+
290
+ Reproduces the `charismactl watch --> results_sent=0` bug: a short run enqueues
291
+ a single result after the subprocess exits (via final_scan). The consumer moves
292
+ it from the queue into the in-memory batch, which empties the queue immediately.
293
+ drain() only waits on queue emptiness, so it returns before the 2s batch-timeout
294
+ flush fires — leaving the result unsent. This mirrors the real CI failure where
295
+ `upload` (which flushes on stop) sent the result but `watch` reported 0.
296
+ """
297
+
298
+ @respx.mock
299
+ def test_drain_flushes_single_pending_result(self, tmp_path: Path) -> None:
300
+ """A single result enqueued just before drain must be SENT by drain().
301
+
302
+ Fails on the buggy implementation (result stuck in batch, results_sent=0);
303
+ passes once drain() forces a batch flush and waits for it.
304
+ """
305
+ config = _make_config()
306
+ queue: PriorityQueue = PriorityQueue()
307
+
308
+ results_route = respx.post(
309
+ "https://charisma.test/api/v1/launches/launch-1/results"
310
+ ).mock(return_value=httpx.Response(200, json={"accepted": 1}))
311
+
312
+ uploader = Uploader(config, queue)
313
+ uploader._launch_id = "launch-1"
314
+
315
+ uploader.start()
316
+
317
+ # Simulate final_scan enqueueing one result AFTER the subprocess exits,
318
+ # then draining — the real watch-mode sequence.
319
+ filepath = _make_result_file(tmp_path, "late-result.json")
320
+ queue.put(FileEvent(path=filepath, category=FileCategory.RESULT))
321
+
322
+ uploader.drain(timeout=5.0)
323
+
324
+ # Contract: after drain returns, the result must already be sent —
325
+ # not left sitting in the batch waiting on stop().
326
+ assert uploader.summary.results_sent == 1, (
327
+ "drain() returned but the pending result was not flushed/sent "
328
+ f"(results_sent={uploader.summary.results_sent}); "
329
+ "batch still held the result — this is the watch results_sent=0 bug"
330
+ )
331
+ assert results_route.called
332
+
333
+ uploader.stop()
File without changes