charisma-cli 0.3.0__tar.gz → 0.3.1__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 (31) hide show
  1. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/.gitignore +4 -0
  2. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/PKG-INFO +23 -7
  3. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/README.md +22 -6
  4. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/pyproject.toml +1 -1
  5. charisma_cli-0.3.1/src/charisma_cli/__init__.py +7 -0
  6. charisma_cli-0.3.1/src/charisma_cli/config.py +149 -0
  7. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/src/charisma_cli/main.py +126 -37
  8. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/src/charisma_cli/uploader.py +93 -11
  9. charisma_cli-0.3.1/tests/test_cli.py +186 -0
  10. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/tests/test_config.py +132 -0
  11. charisma_cli-0.3.1/tests/test_open_launch_payload.py +152 -0
  12. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/tests/test_uploader.py +143 -0
  13. charisma_cli-0.3.0/src/charisma_cli/__init__.py +0 -3
  14. charisma_cli-0.3.0/src/charisma_cli/config.py +0 -91
  15. charisma_cli-0.3.0/tests/test_cli.py +0 -49
  16. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/src/charisma_cli/launch_url.py +0 -0
  17. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/src/charisma_cli/models.py +0 -0
  18. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/src/charisma_cli/parser.py +0 -0
  19. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/src/charisma_cli/retry.py +0 -0
  20. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/src/charisma_cli/subprocess_mgr.py +0 -0
  21. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/src/charisma_cli/watcher.py +0 -0
  22. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/tests/__init__.py +0 -0
  23. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/tests/conftest.py +0 -0
  24. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/tests/test_integration.py +0 -0
  25. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/tests/test_launch_url.py +0 -0
  26. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/tests/test_models.py +0 -0
  27. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/tests/test_parser.py +0 -0
  28. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/tests/test_retry.py +0 -0
  29. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/tests/test_silent_mode.py +0 -0
  30. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/tests/test_subprocess_mgr.py +0 -0
  31. {charisma_cli-0.3.0 → charisma_cli-0.3.1}/tests/test_watcher.py +0 -0
@@ -136,3 +136,7 @@ allure-results/*
136
136
  allure-results-live/
137
137
  *tasks.meta.json
138
138
  *.code-workspace
139
+
140
+ # Session-specific work-state tracking (see .kiro update-work-state skill)
141
+ .current-task.md
142
+ .agents/pipeline-status.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: charisma-cli
3
- Version: 0.3.0
3
+ Version: 0.3.1
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
@@ -75,15 +75,31 @@ charismactl watch --results allure-results -- uv run pytest -n 4 tests/
75
75
  | `CHARISMA_TOKEN` | Yes | Service or CI bearer token |
76
76
  | `CHARISMA_PROJECT_ID` | Yes | Project alias for result routing |
77
77
  | `CHARISMA_RESULTS` | No | Path to allure-results (default: `allure-results`) |
78
+ | `CHARISMA_RUN_KEY` | No | Run identity / merge key (`--run-key` fallback) |
79
+ | `BUILD_ID` | No | CI build identifier (`--build-id` fallback; `CHARISMA_BUILD_ID` also honored) |
80
+ | `BUILD_PLAN_KEY` | No | Build plan key (`--build-plan-key` fallback; `CHARISMA_BUILD_PLAN_KEY` also honored) |
81
+ | `ENVIRONMENT` | No | Environment code (`--environment` fallback; `CHARISMA_ENVIRONMENT` also honored) |
82
+ | `COMPONENT` | No | Component alias (`--component` fallback; `CHARISMA_COMPONENT` also honored) |
83
+ | `SOURCE_URL` | No | CI/CD build link (`--source-url` fallback; `CHARISMA_SOURCE_URL` also honored) |
78
84
 
79
85
  ## Flags
80
86
 
81
- | Flag | Description |
82
- | ---------------- | -------------------------------------------- |
83
- | `--results PATH` | Allure results directory (overrides env var) |
84
- | `--project ID` | Project alias (overrides env var) |
85
- | `--silent` | Don't fail pipeline if upload errors occur |
86
- | `--skip-too-big` | Skip result files larger than 2MB |
87
+ Both `upload` and `watch` accept the identical option set.
88
+
89
+ | Flag | Description |
90
+ | -------------------- | --------------------------------------------------------------- |
91
+ | `--endpoint URL` | Charisma API endpoint (overrides `CHARISMA_ENDPOINT`) |
92
+ | `--token TOKEN` | Bearer token (overrides `CHARISMA_TOKEN`) |
93
+ | `--results PATH` | Allure results directory (overrides env var) |
94
+ | `--project ID` | Project alias (overrides env var) |
95
+ | `--silent` | Don't fail pipeline if upload errors occur |
96
+ | `--skip-too-big` | Skip result files larger than 2MB |
97
+ | `--component ALIAS` | Component alias within the project |
98
+ | `--source-url URL` | CI/CD build link for this run |
99
+ | `--build-plan-key K` | Explicit build plan key for resolution (forwarded as `buildPlanKey`) |
100
+ | `--build-id ID` | CI build identifier for this run (forwarded as `buildId`) |
101
+ | `--environment ENV` | Environment code (e.g. `qat`, `prod`), stored on the launch |
102
+ | `--run-key KEY` | Run identity / merge key (reruns + parallel workers share it) |
87
103
 
88
104
  ## Launch URL in CI
89
105
 
@@ -50,15 +50,31 @@ charismactl watch --results allure-results -- uv run pytest -n 4 tests/
50
50
  | `CHARISMA_TOKEN` | Yes | Service or CI bearer token |
51
51
  | `CHARISMA_PROJECT_ID` | Yes | Project alias for result routing |
52
52
  | `CHARISMA_RESULTS` | No | Path to allure-results (default: `allure-results`) |
53
+ | `CHARISMA_RUN_KEY` | No | Run identity / merge key (`--run-key` fallback) |
54
+ | `BUILD_ID` | No | CI build identifier (`--build-id` fallback; `CHARISMA_BUILD_ID` also honored) |
55
+ | `BUILD_PLAN_KEY` | No | Build plan key (`--build-plan-key` fallback; `CHARISMA_BUILD_PLAN_KEY` also honored) |
56
+ | `ENVIRONMENT` | No | Environment code (`--environment` fallback; `CHARISMA_ENVIRONMENT` also honored) |
57
+ | `COMPONENT` | No | Component alias (`--component` fallback; `CHARISMA_COMPONENT` also honored) |
58
+ | `SOURCE_URL` | No | CI/CD build link (`--source-url` fallback; `CHARISMA_SOURCE_URL` also honored) |
53
59
 
54
60
  ## Flags
55
61
 
56
- | Flag | Description |
57
- | ---------------- | -------------------------------------------- |
58
- | `--results PATH` | Allure results directory (overrides env var) |
59
- | `--project ID` | Project alias (overrides env var) |
60
- | `--silent` | Don't fail pipeline if upload errors occur |
61
- | `--skip-too-big` | Skip result files larger than 2MB |
62
+ Both `upload` and `watch` accept the identical option set.
63
+
64
+ | Flag | Description |
65
+ | -------------------- | --------------------------------------------------------------- |
66
+ | `--endpoint URL` | Charisma API endpoint (overrides `CHARISMA_ENDPOINT`) |
67
+ | `--token TOKEN` | Bearer token (overrides `CHARISMA_TOKEN`) |
68
+ | `--results PATH` | Allure results directory (overrides env var) |
69
+ | `--project ID` | Project alias (overrides env var) |
70
+ | `--silent` | Don't fail pipeline if upload errors occur |
71
+ | `--skip-too-big` | Skip result files larger than 2MB |
72
+ | `--component ALIAS` | Component alias within the project |
73
+ | `--source-url URL` | CI/CD build link for this run |
74
+ | `--build-plan-key K` | Explicit build plan key for resolution (forwarded as `buildPlanKey`) |
75
+ | `--build-id ID` | CI build identifier for this run (forwarded as `buildId`) |
76
+ | `--environment ENV` | Environment code (e.g. `qat`, `prod`), stored on the launch |
77
+ | `--run-key KEY` | Run identity / merge key (reruns + parallel workers share it) |
62
78
 
63
79
  ## Launch URL in CI
64
80
 
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
 
6
6
  [project]
7
7
  name = "charisma-cli"
8
- version = "0.3.0"
8
+ version = "0.3.1"
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,7 @@
1
+ """charisma-cli: Stream allure results to Charisma in real time."""
2
+
3
+ # Single source of truth for the CLI version. At build time the wheel metadata
4
+ # is generated from pyproject.toml [project].version, so keeping this literal
5
+ # equal to pyproject guarantees `--version` reports the published version. The
6
+ # prepare-commit step owns the PATCH bump — do not hand-bump here.
7
+ __version__ = "0.3.1"
@@ -0,0 +1,149 @@
1
+ """Configuration dataclass with env var + CLI flag resolution."""
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+ import click
7
+
8
+
9
+ def _resolve_env(bare_name: str) -> str | None:
10
+ """Resolve an env var by its bare name, falling back to the CHARISMA_ prefix.
11
+
12
+ Bare-name-first (e.g. ``BUILD_ID``) keeps parity with CI systems that export
13
+ generic build metadata, while the ``CHARISMA_`` prefixed form (e.g.
14
+ ``CHARISMA_BUILD_ID``) is honored as a namespaced fallback. Returns the first
15
+ non-empty value, or None.
16
+ """
17
+ for name in (bare_name, f"CHARISMA_{bare_name}"):
18
+ value = os.getenv(name)
19
+ if value and value.strip():
20
+ return value.strip()
21
+ return None
22
+
23
+
24
+ @dataclass
25
+ class Config:
26
+ """Resolved configuration for charismactl.
27
+
28
+ Fields are populated via from_env() which merges CLI flag overrides
29
+ with environment variable fallbacks.
30
+ """
31
+
32
+ endpoint: str
33
+ token: str
34
+ project: str
35
+ results_dir: str
36
+ silent: bool
37
+ skip_too_big: bool
38
+ drain_timeout: int
39
+ build_id: str | None
40
+ commit_sha: str | None
41
+ branch: str | None
42
+ component: str | None = None
43
+ source_url: str | None = None
44
+ build_plan_key: str | None = None
45
+ environment: str | None = None
46
+ name: str | None = None
47
+ tags: list[str] | None = None
48
+ run_key: str | None = None
49
+
50
+ @classmethod
51
+ def from_env(
52
+ cls,
53
+ *,
54
+ endpoint: str | None = None,
55
+ token: str | None = None,
56
+ project: str | None = None,
57
+ results: str | None = None,
58
+ silent: bool = False,
59
+ skip_too_big: bool = False,
60
+ drain_timeout: int = 30,
61
+ component: str | None = None,
62
+ source_url: str | None = None,
63
+ build_plan_key: str | None = None,
64
+ build_id: str | None = None,
65
+ environment: str | None = None,
66
+ run_key: str | None = None,
67
+ ) -> "Config":
68
+ """Build Config by resolving CLI flags over environment variables.
69
+
70
+ Resolution order: CLI flag (if not None) > env var > default.
71
+
72
+ Args:
73
+ endpoint: --endpoint flag override
74
+ token: --token flag override
75
+ project: --project flag override
76
+ results: --results flag override
77
+ silent: --silent flag
78
+ skip_too_big: --skip-too-big flag
79
+ drain_timeout: drain timeout in seconds
80
+ component: --component flag override (component alias within the
81
+ project). Env: COMPONENT, fallback CHARISMA_COMPONENT.
82
+ source_url: --source-url flag override (CI/CD build link). Env:
83
+ SOURCE_URL, fallback CHARISMA_SOURCE_URL.
84
+ build_plan_key: --build-plan-key flag override (explicit build plan
85
+ key). Env: BUILD_PLAN_KEY, fallback CHARISMA_BUILD_PLAN_KEY.
86
+ build_id: --build-id flag override (CI build identifier). Env:
87
+ BUILD_ID, fallback CHARISMA_BUILD_ID. Flag wins over env.
88
+ environment: --environment flag override (env code, e.g. "qat").
89
+ Persisted to the first-class launch environment column. Env:
90
+ ENVIRONMENT, fallback CHARISMA_ENVIRONMENT.
91
+ run_key: --run-key flag override. Client-supplied merge key: the
92
+ backend derives the launch id as uuid5(project/run_key) and
93
+ get-or-creates, so reruns and parallel producers sharing a key
94
+ land in one launch. Empty/whitespace resolves to None (a new
95
+ random launch, unchanged behavior).
96
+
97
+ Returns:
98
+ Fully resolved Config instance.
99
+
100
+ Raises:
101
+ click.UsageError: If required fields (endpoint, token) are missing.
102
+ """
103
+ # Connection config resolves from the CHARISMA_-prefixed name ONLY (no
104
+ # bare form): a generic ENDPOINT/TOKEN/PROJECT_ID/RUN_KEY in the
105
+ # environment must not silently reconfigure the client. Build/run
106
+ # metadata below uses _resolve_env (bare-first, prefixed fallback) so it
107
+ # picks up generic CI variables. Do NOT "unify" these — the split is
108
+ # intentional and mirrors pytest-charisma (bare_env_var=None for config).
109
+ resolved_endpoint = (endpoint or os.getenv("CHARISMA_ENDPOINT", "")).strip()
110
+ resolved_token = (token or os.getenv("CHARISMA_TOKEN", "")).strip()
111
+ resolved_project = (project or os.getenv("CHARISMA_PROJECT_ID", "")).strip()
112
+ resolved_results = results or os.getenv("CHARISMA_RESULTS", "allure-results")
113
+ resolved_run_key = (run_key or os.getenv("CHARISMA_RUN_KEY", "")).strip() or None
114
+ resolved_component = (component or _resolve_env("COMPONENT") or "").strip() or None
115
+ resolved_source_url = (source_url or _resolve_env("SOURCE_URL") or "").strip() or None
116
+ resolved_build_plan_key = (build_plan_key or _resolve_env("BUILD_PLAN_KEY") or "").strip() or None
117
+ resolved_build_id = (build_id or _resolve_env("BUILD_ID") or "").strip() or None
118
+ resolved_environment = (environment or _resolve_env("ENVIRONMENT") or "").strip() or None
119
+ # Human-readable launch name (LAUNCH_NAME) and comma-separated tags
120
+ # (TAGS, e.g. "CI/CD,performance") shown on the Launches page.
121
+ resolved_name = (_resolve_env("LAUNCH_NAME") or "").strip() or None
122
+ resolved_tags = [t.strip() for t in (_resolve_env("TAGS") or "").split(",") if t.strip()] or None
123
+
124
+ if not resolved_endpoint:
125
+ raise click.UsageError(
126
+ "Missing required configuration: endpoint. Set CHARISMA_ENDPOINT or pass --endpoint."
127
+ )
128
+ if not resolved_token:
129
+ raise click.UsageError("Missing required configuration: token. Set CHARISMA_TOKEN or pass --token.")
130
+
131
+ return cls(
132
+ endpoint=resolved_endpoint,
133
+ token=resolved_token,
134
+ project=resolved_project,
135
+ results_dir=resolved_results,
136
+ silent=silent,
137
+ skip_too_big=skip_too_big,
138
+ drain_timeout=drain_timeout,
139
+ build_id=resolved_build_id,
140
+ commit_sha=_resolve_env("COMMIT_SHA"),
141
+ branch=_resolve_env("BRANCH"),
142
+ component=resolved_component,
143
+ source_url=resolved_source_url,
144
+ build_plan_key=resolved_build_plan_key,
145
+ environment=resolved_environment,
146
+ name=resolved_name,
147
+ tags=resolved_tags,
148
+ run_key=resolved_run_key,
149
+ )
@@ -2,6 +2,7 @@
2
2
 
3
3
  import signal
4
4
  import sys
5
+ from collections.abc import Callable
5
6
  from pathlib import Path
6
7
  from queue import PriorityQueue
7
8
 
@@ -39,6 +40,11 @@ def _resolve_config(
39
40
  results: str,
40
41
  silent: bool,
41
42
  skip_too_big: bool,
43
+ component: str | None = None,
44
+ source_url: str | None = None,
45
+ build_plan_key: str | None = None,
46
+ build_id: str | None = None,
47
+ environment: str | None = None,
42
48
  run_key: str | None = None,
43
49
  ) -> Config:
44
50
  """Resolve CLI config from flags + env vars. Re-raises UsageError on failure."""
@@ -49,6 +55,11 @@ def _resolve_config(
49
55
  results=results,
50
56
  silent=silent,
51
57
  skip_too_big=skip_too_big,
58
+ component=component,
59
+ source_url=source_url,
60
+ build_plan_key=build_plan_key,
61
+ build_id=build_id,
62
+ environment=environment,
52
63
  run_key=run_key,
53
64
  )
54
65
 
@@ -135,23 +146,50 @@ def _print_summary(uploader: Uploader) -> None:
135
146
  )
136
147
 
137
148
 
149
+ def _common_options(func: Callable) -> Callable:
150
+ """Apply the shared connection + launch-metadata options to a command.
151
+
152
+ `watch` and `upload` accept the identical option set; defining it once here
153
+ keeps them from drifting (a new launch field is added in one place, not two).
154
+ Decorators apply bottom-up, so the visible order on the command matches the
155
+ order below.
156
+ """
157
+ options = [
158
+ click.option("--endpoint", envvar="CHARISMA_ENDPOINT", default=None, help="Charisma API endpoint."),
159
+ click.option("--token", envvar="CHARISMA_TOKEN", default=None, help="Bearer token."),
160
+ click.option(
161
+ "--results", envvar="CHARISMA_RESULTS", default="allure-results", help="Allure results directory."
162
+ ),
163
+ click.option("--project", envvar="CHARISMA_PROJECT_ID", required=True, help="Project alias."),
164
+ click.option("--silent", is_flag=True, default=False, help="Don't fail if upload errors occur."),
165
+ click.option("--skip-too-big", is_flag=True, default=False, help="Skip result files larger than 2MB."),
166
+ click.option("--component", default=None, help="Component alias within the project."),
167
+ click.option("--source-url", default=None, help="CI/CD build link for this run."),
168
+ click.option("--build-plan-key", default=None, help="Explicit build plan key for resolution."),
169
+ click.option("--build-id", default=None, help="CI build identifier for this run (forwarded as buildId)."),
170
+ click.option(
171
+ "--environment",
172
+ default=None,
173
+ help="Environment code (e.g. 'qat', 'prod'). Stored as the launch environment.",
174
+ ),
175
+ click.option(
176
+ "--run-key",
177
+ envvar="CHARISMA_RUN_KEY",
178
+ default=None,
179
+ help="Run identity / merge key (NOT the build id). Producers sharing this key "
180
+ "stream into one launch (launch id = uuid5(project/run-key)) — powers "
181
+ "rerun-in-place and parallel workers; reruns record retry attempts. Use your "
182
+ "CI run/pipeline id (e.g. GITHUB_RUN_ID) so retries merge but separate runs "
183
+ "stay separate. Omit for a new launch.",
184
+ ),
185
+ ]
186
+ for option in reversed(options):
187
+ func = option(func)
188
+ return func
189
+
190
+
138
191
  @cli.command()
139
- @click.option("--endpoint", envvar="CHARISMA_ENDPOINT", default=None, help="Charisma API endpoint.")
140
- @click.option("--token", envvar="CHARISMA_TOKEN", default=None, help="Bearer token.")
141
- @click.option("--results", envvar="CHARISMA_RESULTS", default="allure-results", help="Allure results directory.")
142
- @click.option("--project", envvar="CHARISMA_PROJECT_ID", required=True, help="Project alias.")
143
- @click.option("--silent", is_flag=True, default=False, help="Don't fail if upload errors occur.")
144
- @click.option("--skip-too-big", is_flag=True, default=False, help="Skip result files larger than 2MB.")
145
- @click.option(
146
- "--run-key",
147
- envvar="CHARISMA_RUN_KEY",
148
- default=None,
149
- help="Run identity / merge key (NOT the build id). Producers sharing this key "
150
- "stream into one launch (launch id = uuid5(project/run-key)) — powers "
151
- "rerun-in-place and parallel workers; reruns record retry attempts. Use your "
152
- "CI run/pipeline id (e.g. GITHUB_RUN_ID) so retries merge but separate runs "
153
- "stay separate. Omit for a new launch.",
154
- )
192
+ @_common_options
155
193
  @click.argument("command", nargs=-1, required=True)
156
194
  def watch(
157
195
  endpoint: str | None,
@@ -160,6 +198,11 @@ def watch(
160
198
  project: str,
161
199
  silent: bool,
162
200
  skip_too_big: bool,
201
+ component: str | None,
202
+ source_url: str | None,
203
+ build_plan_key: str | None,
204
+ build_id: str | None,
205
+ environment: str | None,
163
206
  run_key: str | None,
164
207
  command: tuple[str, ...],
165
208
  ) -> None:
@@ -167,7 +210,20 @@ def watch(
167
210
 
168
211
  Usage: charismactl watch --project my-project -- pytest -n 4 tests/
169
212
  """
170
- config = _resolve_config(endpoint, token, project, results, silent, skip_too_big, run_key)
213
+ config = _resolve_config(
214
+ endpoint,
215
+ token,
216
+ project,
217
+ results,
218
+ silent,
219
+ skip_too_big,
220
+ component,
221
+ source_url,
222
+ build_plan_key,
223
+ build_id,
224
+ environment,
225
+ run_key,
226
+ )
171
227
 
172
228
  queue: PriorityQueue[FileEvent] = PriorityQueue()
173
229
  uploader = Uploader(config, queue)
@@ -220,7 +276,12 @@ def watch(
220
276
 
221
277
  # Drain and close — stop the watcher AFTER drain so the consumer's final
222
278
  # queue flush still sees anything the observer enqueued during drain.
223
- uploader.drain(timeout=config.drain_timeout)
279
+ # drain() is progress-based and returns False if it stalled or any send
280
+ # failed — same partial-send guard as `upload`. In watch mode a dropped
281
+ # upload must not be masked by a passing test run (exit 0), so an incomplete
282
+ # drain forces a non-zero exit while still preserving a failing test's own
283
+ # exit code.
284
+ fully_drained = uploader.drain(timeout=config.drain_timeout)
224
285
  watcher.stop()
225
286
  uploader.stop()
226
287
 
@@ -232,26 +293,22 @@ def watch(
232
293
 
233
294
  _warn_if_results_unsent(uploader, config)
234
295
  _print_summary(uploader)
296
+
297
+ if not fully_drained:
298
+ click.echo(
299
+ "Error: upload did not finish — the consumer stalled or results "
300
+ "failed to send; some results were dropped. Exiting non-zero so this "
301
+ "run is not treated as a successful upload.",
302
+ err=True,
303
+ )
304
+ # Preserve a failing test's exit code; otherwise surface the upload
305
+ # failure as exit 1 instead of letting a passing test mask it.
306
+ sys.exit(exit_code if exit_code != 0 else 1)
235
307
  sys.exit(exit_code)
236
308
 
237
309
 
238
310
  @cli.command()
239
- @click.option("--endpoint", envvar="CHARISMA_ENDPOINT", default=None, help="Charisma API endpoint.")
240
- @click.option("--token", envvar="CHARISMA_TOKEN", default=None, help="Bearer token.")
241
- @click.option("--results", envvar="CHARISMA_RESULTS", default="allure-results", help="Allure results directory.")
242
- @click.option("--project", envvar="CHARISMA_PROJECT_ID", required=True, help="Project alias.")
243
- @click.option("--silent", is_flag=True, default=False, help="Don't fail if upload errors occur.")
244
- @click.option("--skip-too-big", is_flag=True, default=False, help="Skip result files larger than 2MB.")
245
- @click.option(
246
- "--run-key",
247
- envvar="CHARISMA_RUN_KEY",
248
- default=None,
249
- help="Run identity / merge key (NOT the build id). Producers sharing this key "
250
- "stream into one launch (launch id = uuid5(project/run-key)) — powers "
251
- "rerun-in-place and parallel workers; reruns record retry attempts. Use your "
252
- "CI run/pipeline id (e.g. GITHUB_RUN_ID) so retries merge but separate runs "
253
- "stay separate. Omit for a new launch.",
254
- )
311
+ @_common_options
255
312
  def upload(
256
313
  endpoint: str | None,
257
314
  token: str | None,
@@ -259,13 +316,31 @@ def upload(
259
316
  project: str,
260
317
  silent: bool,
261
318
  skip_too_big: bool,
319
+ component: str | None,
320
+ source_url: str | None,
321
+ build_plan_key: str | None,
322
+ build_id: str | None,
323
+ environment: str | None,
262
324
  run_key: str | None,
263
325
  ) -> None:
264
326
  """Upload completed allure-results to Charisma (post-execution).
265
327
 
266
328
  Usage: charismactl upload --project my-project --results allure-results
267
329
  """
268
- config = _resolve_config(endpoint, token, project, results, silent, skip_too_big, run_key)
330
+ config = _resolve_config(
331
+ endpoint,
332
+ token,
333
+ project,
334
+ results,
335
+ silent,
336
+ skip_too_big,
337
+ component,
338
+ source_url,
339
+ build_plan_key,
340
+ build_id,
341
+ environment,
342
+ run_key,
343
+ )
269
344
 
270
345
  queue: PriorityQueue[FileEvent] = PriorityQueue()
271
346
  uploader = Uploader(config, queue)
@@ -292,9 +367,14 @@ def upload(
292
367
  if category is not None:
293
368
  queue.put(FileEvent(path=filepath, category=category))
294
369
 
295
- # Process and drain
370
+ # Process and drain. drain() is progress-based: it runs to completion for a
371
+ # healthy upload of any size and only returns False if the consumer STALLS
372
+ # (no progress for drain_timeout seconds). A False return means results were
373
+ # dropped — we must still close the launch and print the summary, then exit
374
+ # non-zero so CI sees a failure instead of a green build with a partial send
375
+ # (the upload results_sent=700/10158-queued bug).
296
376
  uploader.start()
297
- uploader.drain(timeout=config.drain_timeout)
377
+ fully_drained = uploader.drain(timeout=config.drain_timeout)
298
378
  uploader.stop()
299
379
 
300
380
  # Close launch
@@ -303,3 +383,12 @@ def upload(
303
383
  uploader.stop()
304
384
 
305
385
  _print_summary(uploader)
386
+
387
+ if not fully_drained:
388
+ click.echo(
389
+ "Error: upload did not finish — the consumer stalled and some results "
390
+ "were not sent. Exiting non-zero so this run is not treated as a "
391
+ "successful upload.",
392
+ err=True,
393
+ )
394
+ sys.exit(1)
@@ -171,7 +171,7 @@ class Uploader:
171
171
  self._client.close()
172
172
  self._client = None
173
173
 
174
- def drain(self, timeout: float) -> None:
174
+ def drain(self, timeout: float) -> bool:
175
175
  """Signal drain and wait until all consumed items are flushed/sent.
176
176
 
177
177
  Waits for BOTH the queue to empty AND the in-memory batch to be flushed.
@@ -182,32 +182,102 @@ class Uploader:
182
182
  result unsent — the ``watch results_sent=0`` bug. Once the queue is empty,
183
183
  this forces a final batch flush so pending results are sent before return.
184
184
 
185
+ Progress-based completion (not a fixed wall-clock cap): a large run (e.g.
186
+ 10k+ parametrizations) legitimately takes minutes to upload, far longer
187
+ than any safe fixed timeout. A fixed deadline silently abandoned every
188
+ still-queued item on expiry — the ``upload`` partial-send bug where a
189
+ 1201-/10k-result run reported ``results_sent=700`` and exited 0 while
190
+ thousands of results sat undrained. Instead, this keeps waiting as long
191
+ as the backlog (queue + unfinished tasks) is shrinking, and only gives up
192
+ when progress STALLS — no item processed for ``timeout`` seconds. A
193
+ healthy upload runs to completion no matter how large; only a genuine
194
+ hang (dead connection, wedged consumer) trips the stall guard.
195
+
185
196
  Args:
186
- timeout: Maximum seconds to wait for the queue+batch to drain.
197
+ timeout: Stall timeout in seconds — the maximum time to wait with NO
198
+ progress before giving up. This is an idle window, not a total
199
+ deadline; total drain time is unbounded while progress continues.
200
+
201
+ Returns:
202
+ True only if EVERY consumed result was accepted by the backend:
203
+ the queue is empty, the batch is flushed, no batch failed
204
+ (``results_failed == 0``), and uploads were not disabled mid-run by
205
+ the circuit breaker (``_disabled``) or a 401 (``_auth_failed``).
206
+ False otherwise. A False return means results were NOT fully sent and
207
+ the caller MUST surface it as a failure (non-zero exit) rather than
208
+ report success. Draining the queue is necessary but NOT sufficient:
209
+ on a connection failure the consumer still dequeues every item (and
210
+ counts it failed) so the backlog reaches 0 — checking only emptiness
211
+ would wrongly report success on a partial send. Success therefore
212
+ requires send-level confirmation, not just an empty queue.
187
213
  """
188
214
  self._drain_event.set()
189
- deadline = time.monotonic() + timeout
190
215
 
191
216
  # Phase 1: wait for the queue to empty AND for the consumer to finish
192
217
  # processing every dequeued item. Checking queue.empty() alone is racy:
193
218
  # an item that has been dequeued but not yet appended to the batch is in
194
219
  # neither the queue nor the batch. unfinished_tasks (decremented by the
195
220
  # consumer's task_done()) closes that window.
196
- while (not self._queue.empty() or self._queue.unfinished_tasks > 0) and time.monotonic() < deadline:
221
+ #
222
+ # Stall detection: track the outstanding backlog and the monotonic time
223
+ # it last decreased. As long as the backlog keeps falling we keep
224
+ # waiting (a big run is slow, not stuck). If the backlog does not move
225
+ # for ``timeout`` seconds we conclude the consumer is wedged and stop.
226
+ def _backlog() -> int:
227
+ return self._queue.qsize() + max(self._queue.unfinished_tasks, 0)
228
+
229
+ # Guard a non-positive stall window: treat it as "no stall tolerance is
230
+ # meaningless" and fall back to a small positive floor, so a healthy but
231
+ # slow run is never abandoned on the first idle 50ms tick.
232
+ stall_timeout = timeout if timeout > 0 else 0.5
233
+
234
+ last_backlog = _backlog()
235
+ last_progress = time.monotonic()
236
+ while _backlog() > 0:
197
237
  time.sleep(0.05)
238
+ current = _backlog()
239
+ if current < last_backlog:
240
+ last_backlog = current
241
+ last_progress = time.monotonic()
242
+ elif time.monotonic() - last_progress >= stall_timeout:
243
+ break
198
244
 
199
- # Phase 2: the consumer is idle and everything is now in the batch (or
200
- # already sent). Force out any partial batch that hasn't hit the 50-item
201
- # or 2s trigger, so drain() honors its contract: everything consumed has
202
- # been sent when it returns.
245
+ # Phase 2: force out any partial batch that hasn't hit the 50-item or 2s
246
+ # trigger, so drain() honors its contract: everything consumed has been
247
+ # sent when it returns fully drained.
203
248
  self._flush_batch()
204
249
 
205
- if not self._queue.empty() or self._queue.unfinished_tasks > 0 or self._batch:
206
- logger.warning(
207
- "Drain timeout expired with %d queued and %d batched items remaining",
250
+ # Success requires BOTH an empty backlog AND send-level confirmation.
251
+ # Draining the queue alone is not enough: on a connection failure the
252
+ # consumer dequeues every item, counts it failed, and empties the queue —
253
+ # so emptiness would falsely report success while results were dropped
254
+ # (the connection-failure variant of the partial-send bug). Require that
255
+ # nothing failed and uploads were not disabled mid-run.
256
+ backlog_clear = self._queue.empty() and self._queue.unfinished_tasks <= 0 and not self._batch
257
+ sends_ok = (
258
+ self._summary.results_failed == 0
259
+ and not self._disabled
260
+ and not self._auth_failed
261
+ )
262
+ fully_drained = backlog_clear and sends_ok
263
+
264
+ if not backlog_clear:
265
+ logger.error(
266
+ "Drain stalled with no progress for %.0fs — %d queued and %d batched items "
267
+ "were NOT sent (results dropped)",
268
+ stall_timeout,
208
269
  self._queue.qsize(),
209
270
  len(self._batch),
210
271
  )
272
+ elif not sends_ok:
273
+ logger.error(
274
+ "Drain completed but %d result(s) failed to send "
275
+ "(uploads_disabled=%s, auth_failed=%s) — results dropped",
276
+ self._summary.results_failed,
277
+ self._disabled,
278
+ self._auth_failed,
279
+ )
280
+ return fully_drained
211
281
 
212
282
  def open_launch(self) -> str | None:
213
283
  """Open a streaming launch via the API.
@@ -239,6 +309,18 @@ class Uploader:
239
309
  payload["commitSha"] = self._config.commit_sha
240
310
  if self._config.branch:
241
311
  payload["branch"] = self._config.branch
312
+ if self._config.component:
313
+ payload["componentAlias"] = self._config.component
314
+ if self._config.source_url:
315
+ payload["sourceUrl"] = self._config.source_url
316
+ if self._config.build_plan_key:
317
+ payload["buildPlanKey"] = self._config.build_plan_key
318
+ if self._config.environment:
319
+ payload["environment"] = self._config.environment
320
+ if self._config.name:
321
+ payload["name"] = self._config.name
322
+ if self._config.tags:
323
+ payload["tags"] = self._config.tags
242
324
 
243
325
  for attempt in range(MAX_RETRIES + 1):
244
326
  try: