Metadata-Version: 2.5
Name: charisma-cli
Version: 0.5.0
Summary: CLI tool that watches allure-results and streams test results + attachments to Charisma.
Author: Charisma Team
License-Expression: MIT
Classifier: Development Status :: 2 - Pre-Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Testing
Requires-Python: >=3.10
Requires-Dist: click<9.0,>=8.0
Requires-Dist: httpx<1.0,>=0.24
Requires-Dist: pydantic<3.0,>=2.0
Requires-Dist: watchdog<5.0,>=3.0
Provides-Extra: dev
Requires-Dist: hypothesis; extra == 'dev'
Requires-Dist: pytest<10.0,>=7.0; extra == 'dev'
Requires-Dist: respx; extra == 'dev'
Description-Content-Type: text/markdown

# charisma-cli

CLI tool that watches an `allure-results` directory during test execution and streams results + attachments to the Charisma ingestion API in real time.

## Installation

```bash
pip install charisma-cli
```

No token, no index URL — works on any laptop and in any CI. The package is published to public PyPI; the Charisma repo itself stays private (only this client wheel is public).

For local development from a checkout of the repo:

```bash
pip install -e packages/charisma-cli
```

## Publishing (maintainers)

Releases publish to public PyPI automatically from the merge-queue workflow using a **PyPI API token**.

> Trusted publishing (OIDC) is not used because this repo runs on **GitHub Enterprise Server**, whose OIDC issuer PyPI does not trust. A stored API token is required instead.

One-time setup (done once by a PyPI project owner):

1. Sign in to [PyPI](https://pypi.org) with the team-owned account (2FA enabled).
2. Go to **Account settings → API tokens → Add API token**. Scope it to the `charisma-cli` project once the project exists; for the very first publish use an account-scoped token, then re-scope it to the project afterward.
3. Copy the token (starts with `pypi-`).
4. In the GitHub Enterprise repo, add it as an Actions secret named **`PYPI_API_TOKEN`** (org-level secret recommended so both packages share it).
5. Bump `version` in `pyproject.toml` and merge — the workflow builds and publishes on the next release.

Rotate the token periodically and store it in the team secret manager.

## Usage

```bash
export CHARISMA_ENDPOINT=https://charisma.company.com
export CHARISMA_TOKEN=<your-service-token>
export CHARISMA_PROJECT_ID=my-project

charismactl watch --results allure-results -- uv run pytest -n 4 tests/
```

## Environment Variables

| Variable              | Required | Description                                        |
| --------------------- | -------- | -------------------------------------------------- |
| `CHARISMA_ENDPOINT`   | Yes      | Base URL of the Charisma API                       |
| `CHARISMA_TOKEN`      | Yes      | Service or CI bearer token                         |
| `CHARISMA_PROJECT_ID` | Yes      | Project alias for result routing                   |
| `CHARISMA_RESULTS`    | No       | Path to allure-results (default: `allure-results`) |
| `CHARISMA_RUN_KEY`    | No       | Run identity / merge key (`--run-key` fallback)    |
| `BUILD_ID`            | No       | CI build identifier (`--build-id` fallback; `CHARISMA_BUILD_ID` also honored) |
| `BUILD_PLAN_KEY`      | No       | Build plan key (`--build-plan-key` fallback; `CHARISMA_BUILD_PLAN_KEY` also honored) |
| `ENVIRONMENT`         | No       | Environment code (`--environment` fallback; `CHARISMA_ENVIRONMENT` also honored) |
| `COMPONENT`           | No       | Component alias (`--component` fallback; `CHARISMA_COMPONENT` also honored) |
| `SOURCE_URL`          | No       | CI/CD build link (`--source-url` fallback; `CHARISMA_SOURCE_URL` also honored) |

## Flags

Both `upload` and `watch` accept the identical option set.

| Flag                 | Description                                                      |
| -------------------- | --------------------------------------------------------------- |
| `--endpoint URL`     | Charisma API endpoint (overrides `CHARISMA_ENDPOINT`)           |
| `--token TOKEN`      | Bearer token (overrides `CHARISMA_TOKEN`)                       |
| `--results PATH`     | Allure results directory (overrides env var)                    |
| `--project ID`       | Project alias (overrides env var)                               |
| `--silent`           | Don't fail pipeline if upload errors occur                      |
| `--skip-too-big`     | Skip result files larger than 2MB                               |
| `--component ALIAS`  | Component alias within the project                              |
| `--source-url URL`   | CI/CD build link for this run                                   |
| `--build-plan-key K` | Explicit build plan key for resolution (forwarded as `buildPlanKey`) |
| `--build-id ID`      | CI build identifier for this run (forwarded as `buildId`)       |
| `--environment ENV`  | Environment code (e.g. `qat`, `prod`), stored on the launch     |
| `--run-key KEY`      | Run identity / merge key (reruns + parallel workers share it)   |

## Launch URL in CI

`charismactl` prints the Charisma launch URL (`Charisma launch: <url>`) and, when running
in GitHub Actions, writes it as a step output named `launch_url`. Attach it to the job
summary or a Teams message:

```yaml
- name: Run tests and stream to Charisma
  id: charisma
  run: charismactl watch --project my-project -- pytest tests/

- name: Add Charisma link to summary
  if: always()
  run: echo "[View Charisma launch](${{ steps.charisma.outputs.launch_url }})" >> "$GITHUB_STEP_SUMMARY"

# The same value can feed a Teams-notification step:
#   text: "Results: ${{ steps.charisma.outputs.launch_url }}"
```
## Read API

Alongside the write path (`watch`/`upload`), the package ships an importable **read client** for
querying launches, results, history, and attachments from Charisma's Phase-1 read API. The client
returns the server JSON **verbatim** (Charisma-native shapes — UUID ids, `{items, total}` envelopes,
per-endpoint casing). It performs no Allure reshaping and is fully decoupled from the upload
machinery.

### Project id is a UUID in read mode

Read is **project-scoped by the project UUID**, not by the write-path `projectAlias`.

- **Write path** (`watch`/`upload`): `CHARISMA_PROJECT_ID` / `--project` is the project **alias**.
- **Read path** (`CharismaReadClient`): the `project_id` argument must be the project **UUID**.

A non-UUID value (for example, passing the write alias to the read client) fails **early** with a
clear `CharismaProjectIdError` naming the UUID-vs-alias distinction — not a late, confusing 404.

### Python read client

```python
from charisma_cli.read_client import (
    CharismaReadClient,
    CharismaNotFoundError,
    CharismaAuthError,
    CharismaReadError,
)

# project_id MUST be the project UUID (NOT the write-path projectAlias).
client = CharismaReadClient(
    endpoint="https://charisma.company.com",
    token="<service-token>",
    project_id="3f2504e0-4f89-41d3-9a0c-0305e82c3301",
)

# --- Launches -------------------------------------------------------------
# list_launches is newest-first (no sort param) and takes up to 9 filters.
page = client.list_launches(offset=0, limit=25, status="failed", branch="main")
for launch in page.items:           # PaginatedLaunchList -> BaseLaunch items (snake_case attrs)
    print(launch.id, launch.name)
print("total:", page.total)

launch = client.get_launch("8a1b...launch-uuid")           # LaunchRecord (snake_case attrs)
stats = client.get_launch_statistic("8a1b...launch-uuid")  # LaunchStatisticRecord -> .counts[]

# --- Results of a launch (tags/metadataTags/zephyrIds populated) -----------
results = client.list_launch_results(
    "8a1b...launch-uuid",
    offset=0,
    limit=25,
    sort_by="startedAt",   # name|status|startedAt|createdAt|durationMs
    sort_order="desc",     # asc|desc
    status="failed",
)

# --- A single result (tags/metadataTags/zephyrIds are always []) -----------
result = client.get_result("c4d5...result-uuid")           # TestResultRecord
execution = client.get_result_execution("c4d5...result-uuid")  # ExecutionRecord
# execution.id / .status / .steps / .beforeHooks / .afterHooks / .backgroundSteps / .attachments

# --- History (TestCaseHistoryRecord) ---------------------------------------
history = client.get_result_history("c4d5...result-uuid")
print(history.history_id, history.stability.passed_count)

# --- Attachments ----------------------------------------------------------
atts = client.list_result_attachments("c4d5...result-uuid")   # PaginatedAttachments (.items/.total)
url = client.get_attachment_url("e6f7...attachment-uuid")     # AttachmentUrl (.download_url/.file_name/.content_type)
content = client.get_attachment_content("e6f7...attachment-uuid")  # AttachmentContent
# content.content / content.file_name / content.truncated  — text-only, truncated=True past the 1 MB cap

# --- Paginate any {items, total} endpoint ----------------------------------
# iter_pages drives offset/limit for you, yielding every item once (page_size
# defaults to 100, the server cap). Pass the list method plus its kwargs.
for launch in client.iter_pages(client.list_launches, status="failed"):
    print(launch.id)

for row in client.iter_pages(
    client.list_launch_results, launch_id="8a1b...launch-uuid", status="failed"
):
    print(row.name, row.status)

client.close()   # or use the client as a context manager: `with CharismaReadClient(...) as client:`
```

All methods return **typed Pydantic models** (from `charisma_cli.read_models`) parsed from the server JSON — e.g. `list_launches` -> `PaginatedLaunchList`, `get_launch` -> `LaunchRecord`, `get_result_history` -> `TestCaseHistoryRecord`. **Model attributes are uniform `snake_case`** (`launch.started_at`, `result.duration_ms`, `history.history_id`), consistent with the rest of the package. The server's wire casing is handled by Pydantic aliases, not the attribute names: `model_validate` accepts the server body as-is, and `model_dump(by_alias=True)` / `model_dump_json(by_alias=True)` reproduce the exact server JSON (camelCase for most endpoints, snake_case for history). `model_dump()` without `by_alias` gives you snake_case. Datetime fields parse to `datetime`; models set `extra="allow"` so a new server field is preserved. Failures raise a typed error:

| Error | Raised on |
| ----- | --------- |
| `CharismaNotFoundError` | HTTP 404 — the launch/result/attachment does not exist |
| `CharismaAuthError` | HTTP 401/403 — token missing, invalid, or lacking access |
| `CharismaReadError` | base error — a 400 (e.g. an invalid sort value), a persistent 5xx after retries, or a transport failure. `CharismaProjectIdError` (non-UUID `project_id`) also subclasses it |

Transient failures (429/5xx/timeouts) are retried with backoff before surfacing as
`CharismaReadError`; terminal statuses (400/401/403/404) are raised immediately, not retried.

### Attributes are snake_case; the wire is reproduced via aliases

Every model exposes **uniform `snake_case` attributes** — there is no per-endpoint attribute casing to remember. The server's on-the-wire casing still differs per endpoint (camelCase for launches/results/attachments, snake_case for `get_result_history`), but that lives in the Pydantic **aliases**, not the attribute names. So you always read `launch.source_url`, `result.duration_ms`, `history.history_id`; and if you need the exact server JSON back, dump with `by_alias=True`. This keeps the Python API consistent while the client stays a faithful mirror of the Phase-1 wire contract.

### Attachment endpoints and the token type

`get_attachment_url` and `get_attachment_content` require a token the Attachments Lambda
accepts. These two endpoints were historically **user-token-only** (`allowed_token_types=["user"]`),
which rejected the CLI's service token. They have been widened to accept **both user and service
tokens** (`["user", "service"]`, ADR-035 / FR-0), so a **service token now works** — client-side
these methods behave exactly like the others, with no token-type special-casing. The `ci` token type
remains excluded from these two endpoints.
