charisma-cli 0.2.2__tar.gz → 0.3.0__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.2.2 → charisma_cli-0.3.0}/PKG-INFO +1 -1
  2. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/pyproject.toml +1 -1
  3. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/src/charisma_cli/config.py +11 -6
  4. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/src/charisma_cli/main.py +26 -2
  5. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/src/charisma_cli/models.py +55 -18
  6. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/src/charisma_cli/uploader.py +235 -60
  7. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_cli.py +16 -0
  8. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_config.py +45 -0
  9. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_uploader.py +414 -32
  10. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/.gitignore +0 -0
  11. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/README.md +0 -0
  12. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/src/charisma_cli/__init__.py +0 -0
  13. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/src/charisma_cli/launch_url.py +0 -0
  14. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/src/charisma_cli/parser.py +0 -0
  15. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/src/charisma_cli/retry.py +0 -0
  16. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/src/charisma_cli/subprocess_mgr.py +0 -0
  17. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/src/charisma_cli/watcher.py +0 -0
  18. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/__init__.py +0 -0
  19. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/conftest.py +0 -0
  20. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_integration.py +0 -0
  21. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_launch_url.py +0 -0
  22. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_models.py +0 -0
  23. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_parser.py +0 -0
  24. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_retry.py +0 -0
  25. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_silent_mode.py +0 -0
  26. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_subprocess_mgr.py +0 -0
  27. {charisma_cli-0.2.2 → charisma_cli-0.3.0}/tests/test_watcher.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: charisma-cli
3
- Version: 0.2.2
3
+ Version: 0.3.0
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
@@ -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.0"
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"
@@ -24,6 +24,7 @@ class Config:
24
24
  build_id: str | None
25
25
  commit_sha: str | None
26
26
  branch: str | None
27
+ run_key: str | None = None
27
28
 
28
29
  @classmethod
29
30
  def from_env(
@@ -36,6 +37,7 @@ class Config:
36
37
  silent: bool = False,
37
38
  skip_too_big: bool = False,
38
39
  drain_timeout: int = 30,
40
+ run_key: str | None = None,
39
41
  ) -> "Config":
40
42
  """Build Config by resolving CLI flags over environment variables.
41
43
 
@@ -49,6 +51,11 @@ class Config:
49
51
  silent: --silent flag
50
52
  skip_too_big: --skip-too-big flag
51
53
  drain_timeout: drain timeout in seconds
54
+ run_key: --run-key flag override. Client-supplied merge key: the
55
+ backend derives the launch id as uuid5(project/run_key) and
56
+ get-or-creates, so reruns and parallel producers sharing a key
57
+ land in one launch. Empty/whitespace resolves to None (a new
58
+ random launch, unchanged behavior).
52
59
 
53
60
  Returns:
54
61
  Fully resolved Config instance.
@@ -60,17 +67,14 @@ class Config:
60
67
  resolved_token = (token or os.getenv("CHARISMA_TOKEN", "")).strip()
61
68
  resolved_project = (project or os.getenv("CHARISMA_PROJECT_ID", "")).strip()
62
69
  resolved_results = results or os.getenv("CHARISMA_RESULTS", "allure-results")
70
+ resolved_run_key = (run_key or os.getenv("CHARISMA_RUN_KEY", "")).strip() or None
63
71
 
64
72
  if not resolved_endpoint:
65
73
  raise click.UsageError(
66
- "Missing required configuration: endpoint. "
67
- "Set CHARISMA_ENDPOINT or pass --endpoint."
74
+ "Missing required configuration: endpoint. Set CHARISMA_ENDPOINT or pass --endpoint."
68
75
  )
69
76
  if not resolved_token:
70
- raise click.UsageError(
71
- "Missing required configuration: token. "
72
- "Set CHARISMA_TOKEN or pass --token."
73
- )
77
+ raise click.UsageError("Missing required configuration: token. Set CHARISMA_TOKEN or pass --token.")
74
78
 
75
79
  return cls(
76
80
  endpoint=resolved_endpoint,
@@ -83,4 +87,5 @@ class Config:
83
87
  build_id=os.getenv("BUILD_ID"),
84
88
  commit_sha=os.getenv("COMMIT_SHA"),
85
89
  branch=os.getenv("BRANCH"),
90
+ run_key=resolved_run_key,
86
91
  )
@@ -39,6 +39,7 @@ def _resolve_config(
39
39
  results: str,
40
40
  silent: bool,
41
41
  skip_too_big: bool,
42
+ run_key: str | None = None,
42
43
  ) -> Config:
43
44
  """Resolve CLI config from flags + env vars. Re-raises UsageError on failure."""
44
45
  return Config.from_env(
@@ -48,6 +49,7 @@ def _resolve_config(
48
49
  results=results,
49
50
  silent=silent,
50
51
  skip_too_big=skip_too_big,
52
+ run_key=run_key,
51
53
  )
52
54
 
53
55
 
@@ -140,6 +142,16 @@ def _print_summary(uploader: Uploader) -> None:
140
142
  @click.option("--project", envvar="CHARISMA_PROJECT_ID", required=True, help="Project alias.")
141
143
  @click.option("--silent", is_flag=True, default=False, help="Don't fail if upload errors occur.")
142
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
+ )
143
155
  @click.argument("command", nargs=-1, required=True)
144
156
  def watch(
145
157
  endpoint: str | None,
@@ -148,13 +160,14 @@ def watch(
148
160
  project: str,
149
161
  silent: bool,
150
162
  skip_too_big: bool,
163
+ run_key: str | None,
151
164
  command: tuple[str, ...],
152
165
  ) -> None:
153
166
  """Watch allure-results and stream to Charisma while running COMMAND.
154
167
 
155
168
  Usage: charismactl watch --project my-project -- pytest -n 4 tests/
156
169
  """
157
- config = _resolve_config(endpoint, token, project, results, silent, skip_too_big)
170
+ config = _resolve_config(endpoint, token, project, results, silent, skip_too_big, run_key)
158
171
 
159
172
  queue: PriorityQueue[FileEvent] = PriorityQueue()
160
173
  uploader = Uploader(config, queue)
@@ -229,6 +242,16 @@ def watch(
229
242
  @click.option("--project", envvar="CHARISMA_PROJECT_ID", required=True, help="Project alias.")
230
243
  @click.option("--silent", is_flag=True, default=False, help="Don't fail if upload errors occur.")
231
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
+ )
232
255
  def upload(
233
256
  endpoint: str | None,
234
257
  token: str | None,
@@ -236,12 +259,13 @@ def upload(
236
259
  project: str,
237
260
  silent: bool,
238
261
  skip_too_big: bool,
262
+ run_key: str | None,
239
263
  ) -> None:
240
264
  """Upload completed allure-results to Charisma (post-execution).
241
265
 
242
266
  Usage: charismactl upload --project my-project --results allure-results
243
267
  """
244
- config = _resolve_config(endpoint, token, project, results, silent, skip_too_big)
268
+ config = _resolve_config(endpoint, token, project, results, silent, skip_too_big, run_key)
245
269
 
246
270
  queue: PriorityQueue[FileEvent] = PriorityQueue()
247
271
  uploader = Uploader(config, queue)
@@ -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):