charisma-cli 0.2.2__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.2.2 → charisma_cli-0.3.1}/.gitignore +4 -0
  2. {charisma_cli-0.2.2 → charisma_cli-0.3.1}/PKG-INFO +23 -7
  3. {charisma_cli-0.2.2 → charisma_cli-0.3.1}/README.md +22 -6
  4. {charisma_cli-0.2.2 → 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.2.2 → charisma_cli-0.3.1}/src/charisma_cli/main.py +130 -17
  8. {charisma_cli-0.2.2 → charisma_cli-0.3.1}/src/charisma_cli/models.py +55 -18
  9. {charisma_cli-0.2.2 → charisma_cli-0.3.1}/src/charisma_cli/uploader.py +327 -70
  10. charisma_cli-0.3.1/tests/test_cli.py +186 -0
  11. {charisma_cli-0.2.2 → charisma_cli-0.3.1}/tests/test_config.py +177 -0
  12. charisma_cli-0.3.1/tests/test_open_launch_payload.py +152 -0
  13. {charisma_cli-0.2.2 → charisma_cli-0.3.1}/tests/test_uploader.py +557 -32
  14. charisma_cli-0.2.2/src/charisma_cli/__init__.py +0 -3
  15. charisma_cli-0.2.2/src/charisma_cli/config.py +0 -86
  16. charisma_cli-0.2.2/tests/test_cli.py +0 -33
  17. {charisma_cli-0.2.2 → charisma_cli-0.3.1}/src/charisma_cli/launch_url.py +0 -0
  18. {charisma_cli-0.2.2 → charisma_cli-0.3.1}/src/charisma_cli/parser.py +0 -0
  19. {charisma_cli-0.2.2 → charisma_cli-0.3.1}/src/charisma_cli/retry.py +0 -0
  20. {charisma_cli-0.2.2 → charisma_cli-0.3.1}/src/charisma_cli/subprocess_mgr.py +0 -0
  21. {charisma_cli-0.2.2 → charisma_cli-0.3.1}/src/charisma_cli/watcher.py +0 -0
  22. {charisma_cli-0.2.2 → charisma_cli-0.3.1}/tests/__init__.py +0 -0
  23. {charisma_cli-0.2.2 → charisma_cli-0.3.1}/tests/conftest.py +0 -0
  24. {charisma_cli-0.2.2 → charisma_cli-0.3.1}/tests/test_integration.py +0 -0
  25. {charisma_cli-0.2.2 → charisma_cli-0.3.1}/tests/test_launch_url.py +0 -0
  26. {charisma_cli-0.2.2 → charisma_cli-0.3.1}/tests/test_models.py +0 -0
  27. {charisma_cli-0.2.2 → charisma_cli-0.3.1}/tests/test_parser.py +0 -0
  28. {charisma_cli-0.2.2 → charisma_cli-0.3.1}/tests/test_retry.py +0 -0
  29. {charisma_cli-0.2.2 → charisma_cli-0.3.1}/tests/test_silent_mode.py +0 -0
  30. {charisma_cli-0.2.2 → charisma_cli-0.3.1}/tests/test_subprocess_mgr.py +0 -0
  31. {charisma_cli-0.2.2 → 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.2.2
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.2.2"
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,12 @@ 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,
48
+ run_key: str | None = None,
42
49
  ) -> Config:
43
50
  """Resolve CLI config from flags + env vars. Re-raises UsageError on failure."""
44
51
  return Config.from_env(
@@ -48,6 +55,12 @@ def _resolve_config(
48
55
  results=results,
49
56
  silent=silent,
50
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,
63
+ run_key=run_key,
51
64
  )
52
65
 
53
66
 
@@ -133,13 +146,50 @@ def _print_summary(uploader: Uploader) -> None:
133
146
  )
134
147
 
135
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
+
136
191
  @cli.command()
137
- @click.option("--endpoint", envvar="CHARISMA_ENDPOINT", default=None, help="Charisma API endpoint.")
138
- @click.option("--token", envvar="CHARISMA_TOKEN", default=None, help="Bearer token.")
139
- @click.option("--results", envvar="CHARISMA_RESULTS", default="allure-results", help="Allure results directory.")
140
- @click.option("--project", envvar="CHARISMA_PROJECT_ID", required=True, help="Project alias.")
141
- @click.option("--silent", is_flag=True, default=False, help="Don't fail if upload errors occur.")
142
- @click.option("--skip-too-big", is_flag=True, default=False, help="Skip result files larger than 2MB.")
192
+ @_common_options
143
193
  @click.argument("command", nargs=-1, required=True)
144
194
  def watch(
145
195
  endpoint: str | None,
@@ -148,13 +198,32 @@ def watch(
148
198
  project: str,
149
199
  silent: bool,
150
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,
206
+ run_key: str | None,
151
207
  command: tuple[str, ...],
152
208
  ) -> None:
153
209
  """Watch allure-results and stream to Charisma while running COMMAND.
154
210
 
155
211
  Usage: charismactl watch --project my-project -- pytest -n 4 tests/
156
212
  """
157
- config = _resolve_config(endpoint, token, project, results, silent, skip_too_big)
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
+ )
158
227
 
159
228
  queue: PriorityQueue[FileEvent] = PriorityQueue()
160
229
  uploader = Uploader(config, queue)
@@ -207,7 +276,12 @@ def watch(
207
276
 
208
277
  # Drain and close — stop the watcher AFTER drain so the consumer's final
209
278
  # queue flush still sees anything the observer enqueued during drain.
210
- 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)
211
285
  watcher.stop()
212
286
  uploader.stop()
213
287
 
@@ -219,16 +293,22 @@ def watch(
219
293
 
220
294
  _warn_if_results_unsent(uploader, config)
221
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)
222
307
  sys.exit(exit_code)
223
308
 
224
309
 
225
310
  @cli.command()
226
- @click.option("--endpoint", envvar="CHARISMA_ENDPOINT", default=None, help="Charisma API endpoint.")
227
- @click.option("--token", envvar="CHARISMA_TOKEN", default=None, help="Bearer token.")
228
- @click.option("--results", envvar="CHARISMA_RESULTS", default="allure-results", help="Allure results directory.")
229
- @click.option("--project", envvar="CHARISMA_PROJECT_ID", required=True, help="Project alias.")
230
- @click.option("--silent", is_flag=True, default=False, help="Don't fail if upload errors occur.")
231
- @click.option("--skip-too-big", is_flag=True, default=False, help="Skip result files larger than 2MB.")
311
+ @_common_options
232
312
  def upload(
233
313
  endpoint: str | None,
234
314
  token: str | None,
@@ -236,12 +316,31 @@ def upload(
236
316
  project: str,
237
317
  silent: bool,
238
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,
324
+ run_key: str | None,
239
325
  ) -> None:
240
326
  """Upload completed allure-results to Charisma (post-execution).
241
327
 
242
328
  Usage: charismactl upload --project my-project --results allure-results
243
329
  """
244
- config = _resolve_config(endpoint, token, project, results, silent, skip_too_big)
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
+ )
245
344
 
246
345
  queue: PriorityQueue[FileEvent] = PriorityQueue()
247
346
  uploader = Uploader(config, queue)
@@ -268,9 +367,14 @@ def upload(
268
367
  if category is not None:
269
368
  queue.put(FileEvent(path=filepath, category=category))
270
369
 
271
- # 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).
272
376
  uploader.start()
273
- uploader.drain(timeout=config.drain_timeout)
377
+ fully_drained = uploader.drain(timeout=config.drain_timeout)
274
378
  uploader.stop()
275
379
 
276
380
  # Close launch
@@ -279,3 +383,12 @@ def upload(
279
383
  uploader.stop()
280
384
 
281
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)
@@ -4,32 +4,69 @@ from dataclasses import dataclass, field
4
4
  from enum import IntEnum
5
5
  from pathlib import Path
6
6
  from typing import Any
7
- from uuid import NAMESPACE_URL, uuid5
8
7
 
8
+ # Backend field-size contract (mirror of backend
9
+ # models/testresults.py::MAX_ERROR_MESSAGE_BYTES / MAX_STACK_TRACE_BYTES).
10
+ # The streaming ingestion API rejects the WHOLE batch (400 INVALID_INPUT) if any
11
+ # result's error_message > 10KB or stack_trace > 100KB (UTF-8 bytes). Keep these
12
+ # byte-identical to the backend; oversized fields spill to an attachment rather
13
+ # than truncate (see uploader._spill_oversized_field).
14
+ MAX_ERROR_MESSAGE_BYTES = 10 * 1024 # 10KB
15
+ MAX_STACK_TRACE_BYTES = 100 * 1024 # 100KB
9
16
 
10
- def backend_result_id(launch_id: str, test_id: str) -> str:
11
- """Derive the result identity the Charisma backend stores and validates against.
12
17
 
13
- The backend computes ``test_results.id`` as
14
- ``uuid5(NAMESPACE_URL, f"{launch_id}/{test_id}")`` (see backend
15
- ``models/testresults.py`` ``to_canonical``). Attachment uploads are validated
16
- against that stored id, so the CLI MUST send the same value as ``resultId`` —
17
- not the raw Allure ``uuid``, which the backend discards. This function is the
18
- single CLI-side mirror of that contract; keep the namespace, separator, and
19
- field order byte-identical to the backend.
18
+ # Cap for a synthesized attachment filename derived from a test's historyId,
19
+ # which is arbitrary client-supplied text (Allure historyId, or fullName
20
+ # fallback) that may contain path separators or be very long.
21
+ MAX_ATTACHMENT_FILENAME_LENGTH = 200
22
+
23
+
24
+ def safe_attachment_filename(history_id: str, suffix: str) -> str:
25
+ """Build a safe ``text/plain`` attachment filename from a test identity.
26
+
27
+ ``history_id`` is arbitrary client-supplied text (Allure ``historyId``, or
28
+ the ``fullName`` fallback) and can contain path separators (``/``, ``\\``),
29
+ ``..``, control characters, or be very long. This strips it to a basename,
30
+ replaces any remaining unsafe characters, and length-caps the stem so the
31
+ multipart filename sent to the backend is well-formed and bounded.
32
+
33
+ Args:
34
+ history_id: The owning result's test identity.
35
+ suffix: A short descriptor appended to the stem (e.g. ``error-message``).
36
+
37
+ Returns:
38
+ A sanitized ``{stem}-{suffix}.txt`` filename.
39
+ """
40
+ # Drop any path structure, then keep only filename-safe characters.
41
+ base = history_id.replace("\\", "/").rsplit("/", 1)[-1]
42
+ safe = "".join(c if (c.isalnum() or c in "-_.") else "_" for c in base)
43
+ stem = safe[:MAX_ATTACHMENT_FILENAME_LENGTH] or "result"
44
+ return f"{stem}-{suffix}.txt"
45
+
46
+
47
+ def head_snippet(text: str, max_bytes: int) -> str:
48
+ """Return the longest UTF-8 prefix of ``text`` that fits in ``max_bytes``.
49
+
50
+ Cuts on a character boundary — never splits a multibyte code point — by
51
+ encoding, slicing the byte string, and decoding with ``errors="ignore"`` to
52
+ drop a trailing partial character. Used to keep an inline head of an
53
+ oversized field (the full value goes to an attachment).
20
54
 
21
55
  Args:
22
- launch_id: The open launch UUID returned by the backend.
23
- test_id: The result's test identity — the SAME value the CLI sends as
24
- ``test_id`` in the results batch. This is the Allure ``historyId``,
25
- falling back to ``fullName`` when ``historyId`` is absent (see
26
- ``parser.parse_result_file``). Matching the backend requires only
27
- that both sides key off this same value, whichever it is.
56
+ text: The source string.
57
+ max_bytes: Maximum size of the returned prefix in UTF-8 bytes.
28
58
 
29
59
  Returns:
30
- The deterministic result id string matching the backend's stored id.
60
+ A prefix of ``text`` whose UTF-8 encoding is ``<= max_bytes`` bytes.
31
61
  """
32
- return str(uuid5(NAMESPACE_URL, f"{launch_id}/{test_id}"))
62
+ encoded = text.encode("utf-8")
63
+ # Clamp: a non-positive budget must yield an empty prefix, never a Python
64
+ # negative slice (encoded[:-n]) which would return almost the whole string.
65
+ if max_bytes <= 0:
66
+ return ""
67
+ if len(encoded) <= max_bytes:
68
+ return text
69
+ return encoded[:max_bytes].decode("utf-8", errors="ignore")
33
70
 
34
71
 
35
72
  class FileCategory(IntEnum):