Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,9 @@ Mirror the existing patterns (e.g. `field-property`, `prompt-config`, `ruleset`)

Details in [`specs/04-testing.md`](./specs/04-testing.md). The shape:

- Tests live in `tests/` and drive the CLI through `click.testing.CliRunner` — no live PolySwarm stack needed.
- Two styles: **SDK-boundary mocks** (`mock.patch('polyswarm_api.api.PolyswarmAPI.<method>')`, e.g. `tests/field_property_test.py`) and **VCR cassettes** (`tests/cli_test.py`) that replay recorded HTTP for end-to-end CLI runs. Cassettes live in `tests/vcr/` (`.vcr` for the HTTP interactions, `.click` for the expected rendered output).
- Tests live in `tests/` and drive command behaviour through `click.testing.CliRunner` — no live PolySwarm stack needed.
- Two styles for that: **SDK-boundary mocks** (`mock.patch('polyswarm_api.api.PolyswarmAPI.<method>')`, e.g. `tests/field_property_test.py`) and **VCR cassettes** (`tests/cli_test.py`) that replay recorded HTTP for end-to-end CLI runs. Cassettes live in `tests/vcr/` (`.vcr` for the HTTP interactions, `.click` for the expected rendered output).
- Pure rendering logic — which line a given field set produces, no command-tree behaviour — is instead unit-tested against the formatter directly (e.g. `tests/known_good_field_test.py`); see the spec's *Style 3* for when that's the right choice.
- VCR is an **efficiency cache, not a requirement** — the suite must pass against a live e2e stack with VCR off. Re-record a cassette by deleting it and re-running the test against a live stack; never hand-edit a cassette or `cp` one from a sibling test.

## Commit + PR hygiene
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "polyswarm"
version = "4.2.1"
version = "4.3.0"
description = "CLI for using the PolySwarm Customer APIs"
readme = "README.md"
authors = [{ name = "PolySwarm Developers", email = "info@polyswarm.io" }]
Expand All @@ -22,7 +22,7 @@ classifiers = [
]

dependencies = [
"polyswarm_api>=4.0.0,<5.0.0",
"polyswarm_api>=4.3.0,<5.0.0",
"click>=7.1",
"colorama>=0.4.6",
"click-log>=0.4.0",
Expand Down Expand Up @@ -51,7 +51,7 @@ include-package-data = true
where = ["src"]

[tool.bumpversion]
current_version = "4.2.1"
current_version = "4.3.0"
commit = true
tag = false
sign_tags = true
Expand Down
111 changes: 98 additions & 13 deletions specs/03-formatters.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ How command output is rendered: the `BaseOutput` interface, the concrete formatt

| Formatter | Module | Output |
|---|---|---|
| `TextOutput` | `text.py` | Human-readable, labelled blocks; honours `--color/--no-color`. Dates via `polyswarm_api.core.parse_isoformat`. |
| `TextOutput` | `text.py` | Human-readable, labelled blocks; honours `--color/--no-color` through `_paint`, the single place `self.color` is read. The five `_white`/`_red`/… helpers all route through it — a convention, not an enforcement point: a new helper calling `click.style` directly would re-break the flag, so add colours by adding a `_paint` caller. Dates via `polyswarm_api.core.parse_isoformat`. |
| `JSONOutput` | `json.py` | Machine-readable JSON (typically the resource's `.json` plus derived fields). |
| `SHA256Output` / `SHA1Output` / `MD5Output` | `hashes.py` | Hash-only output — prints the relevant digest per result. |

Expand All @@ -45,19 +45,104 @@ Formatters don't set exit codes — that's `ExceptionHandlingGroup`'s job, by ex
**green** "Known Good" signal (a stronger benign signal than "clean") instead of
the misleading "no engines responded — rescan now" a window-closed/no-assertion
instance would otherwise get. It is gated by a single
`is_known_good = (state == 'KNOWN_GOOD') or known_good_sources` flag:
`is_known_good = (state == 'KNOWN_GOOD')` flag:

- **`state == 'KNOWN_GOOD'`** (the SDK's `ArtifactInstance.state`, the friendly
bounty-state name) is the reliable signal — it fires even for a scan-bypassed
instance that carries **no** feed metadata.
bounty-state name) is **the** signal, and the only one — it is the server's single
reliable statement that this artifact is known-good and its bytes are withheld, and
it fires even for a scan-bypassed instance that carries **no** feed metadata. There
is deliberately no separate "bytes withheld" field to consult.

**The wire dependency, and where it is actually pinned.** `state` is load-bearing with no
fallback, and a Style-3 test cannot see the transport boundary — it compares against a dict the
test wrote itself. So the key and the label were **read off the server's serializer**, not
inferred: `ArtifactInstanceSerializer` emits `'state': instance.state.name`, and
`BountyState.KNOWN_GOOD.name` is the exact string `'KNOWN_GOOD'`. What would catch a *future*
rename is the server's own suite (which asserts that field and the `/v3/sample` status), not a
cassette here: this repo's CI replays frozen recordings with no VCR-off e2e job, so a recorded
body would keep replaying the old shape after a rename. If a live-e2e job is ever added here, a
`cli_test.py` cassette over a known-good hash becomes the right place to pin it.
- **`known_good_sources`** (the sorted flagging-feed names, from
`ArtifactInstance.known_good`) is the richer signal: when present, the
**Detections** line names the feeds ("…known-good binary (flagged by: …); it is
not scanned."); otherwise it reads "…is a known-good binary; it is not scanned."
It also keeps known-good rendering working against an older SDK that has the feed
list but not `.state`.

The **Status** line reads "Known good" whenever `is_known_good`. Both attributes are
read with `getattr(..., None)` so a CLI on an older SDK (missing either field)
renders exactly as before. `JSONOutput` needs no change — it dumps the resource's
`ArtifactInstance.known_good`) only **shapes the message** for an instance already
known-good by state: when present, the **Detections** line names the feeds
("…known-good binary (flagged by: …); it is not scanned."); otherwise it reads
"…is a known-good binary; it is not scanned." It must **never** decide
known-goodness — the server emits the feed list for *any* instance whose sha256
matches a known-good record, including a fully scanned one with real
detections/PolyScore, so treating it as the signal would render a scanned artifact
as "known-good … not scanned". It is therefore read as `[]` unless `is_known_good` —
a statement of that coupling, not a guard: the list is only ever read inside the
known-good branch, so the ternary can't change what renders today. Keep it, and keep
reading the feeds through it if a second call site ever appears.

### A known-good instance that also carries results

Known-goodness and collected results are **not** mutually exclusive. An instance that was
scanned before its artifact was catalogued gets reconciled to `KNOWN_GOOD` with its
assertions, detections and PolyScore deliberately preserved, so that pairing is something
a client really receives — the rendering must state both facts without claiming the
artifact was never scanned. The trailing clause of the known-good **Detections** line
therefore switches on `instance.valid_assertions`:

- **no valid assertions** → "…is a known-good binary; it is not scanned." — the withheld,
never-scanned case.
- **valid assertions, window closed** → "…is a known-good binary; N/M engines reported
malicious." — the same count the ordinary window-closed branch renders, folded into the
known-good sentence.
- **valid assertions, window still open** → "…is a known-good binary; its scan has not
finished running yet." Every other **Detections** branch guards its counts on
`window_closed`, because an open window's numbers are not final. Unreachable through the
server's reconciliation — verified against its `RECONCILABLE_STATES`, which is `STORED`
(a row that was never submitted, so it carries no assertions) plus `SETTLED` **and only
when `window_closed`**; the ingest gate's own path sets `window_closed = True`. So this is
a guard against a future state rather than a case seen in practice, stated here so the
parity with the other branches reads as intentional. If a reconciled row ever *can* carry
an open window alongside preserved malicious assertions, redden on `malicious_assertions`
alone and keep only the *text* guarded on the window — the sentence would still be
accurate, and green on a malicious detection is the mis-signal this rule exists to fix.

The flagging-feed attribution ("(flagged by: …)") is orthogonal and applies to all three. In
every branch the per-engine verdict list, the PolyScore line and "Status: Known good"
render as usual; the switch exists so "it is not scanned" is never printed directly above a
list of engine verdicts.

**Colour: any malicious verdict outranks the withheld-bytes signal.** The line is **green**
except on the *count* branch (valid assertions, window closed) when at least one of them is
malicious, where it is **red** — the same threshold and the same colour the ordinary branch
applies to that same count (1/50 reddens there too). The clause and its colour are chosen in
the **same** `if`/`elif`, not by a second condition: written separately they had to agree by
coincidence, and only the SDK's guarantee that `malicious_assertions ⊆ valid_assertions` (both
filter on `mask`) kept them in step — a fact about another repo propping up this rendering. Known-goodness is the
dominant *fact*, but it is not a stronger *warning*: rendering "40/50 engines reported
malicious" in green would be a weaker signal than the very same instance produced before it
was reconciled, which is the class of mis-signal this rendering exists to fix. "Status: Known
good" stays green in both cases — it labels the catalogue status (the counterpart of "Status:
Assertion window closed"), not the verdict. The colour decision is invisible to a test that unstyles its
output, so it is pinned against the **styled** render — `TextOutput(color=True)` read without
`click.unstyle`, which is what `_render_styled` in `known_good_field_test.py` exists for (both
halves matter: the flag makes it paint, the missing `unstyle` keeps the codes). Whether the
`--color/--no-color` **flag** reaches that rendering at all is a separate question a formatter
unit test cannot answer; `test_color_flag_reaches_the_text_formatter` (`cli_test.py`) covers it
through `CliRunner(… color=True)`.

**Scope of `--no-color`.** It governs the text formatter (via `_paint`), `PrettyJSONOutput`
(whose `_to_json` skips the pygments `ClickFormatter` when the flag is off — plain `JSONOutput`
emits unstyled JSON and has nothing to honour), and the log prefix
(`setup_logging(verbosity, color=…)` — the `NamedColorFormatter` used to style unconditionally,
so `polyswarm --no-color -v …` still emitted a green prefix on a tty). It does not attempt to
suppress colour inside third-party output.

The **Status** line reads "Known good" whenever `is_known_good`, except on a **failed**
instance — "Status: Failed" is ordered first and the known-good **Detections** branch is
gated on `not instance.failed`, so a failure is reported as a failure and never as
"…it is not scanned".

Both attributes are read with `getattr(..., None)` so a CLI on an older SDK (missing
either field) never raises `AttributeError`; an SDK without `.state` simply never takes
the known-good branch, which is the safe fallback — the pre-known-good rendering. That
degradation is belt-and-braces, not a supported configuration: `.state` is load-bearing
here with no substitute. Both attributes ship in SDK **4.1.0**, but the dependency floor is
`polyswarm_api>=4.2.0` — set by two *other* behaviours the CLI depends on, both of which
fail silently on 4.1.0 (see [`05-sdk-contract.md`](./05-sdk-contract.md) §Version pin) — so
every supported install has them. `JSONOutput` needs no change — it dumps the resource's
`.json`, which already carries the raw `state` and `known_good` keys.
10 changes: 8 additions & 2 deletions specs/04-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

## Scope

How the CLI is tested: the `CliRunner` harness, the two mocking styles (SDK-boundary mocks vs VCR cassettes), the cassette layout and record workflow, and how to run the suite. Files: `tests/`, `tests/vcr/`, `src/conftest.py`, `pyproject.toml` (`[project.optional-dependencies].tests`, `[tool.pytest.ini_options]`).
How the CLI is tested: the `CliRunner` harness, the two mocking styles (SDK-boundary mocks vs VCR cassettes), the formatter-unit style for pure rendering, the cassette layout and record workflow, and how to run the suite. Files: `tests/`, `tests/vcr/`, `src/conftest.py`, `pyproject.toml` (`[project.optional-dependencies].tests`, `[tool.pytest.ini_options]`).

## Invariants

- **Tests drive the CLI through `click.testing.CliRunner`** — they invoke the real command tree, not internal functions. No live PolySwarm stack is required.
- **Anything that is command behaviour is driven through `click.testing.CliRunner`** — argument parsing, the SDK call, the wiring, the exit code: exercise the real command tree, never an internal function standing in for it. No live PolySwarm stack is required. The one sanctioned exception is pure rendering logic — see [Style 3](#style-3--formatter-unit-tests).
- **Mock at the SDK boundary, or replay HTTP with VCR — never both for the same path.** A test either patches `polyswarm_api.api.PolyswarmAPI.<method>` (unit-style) or lets VCR replay recorded HTTP (end-to-end). The CLI's own code is exercised either way.
- **VCR is an efficiency cache, not a load-bearing requirement.** The suite must pass against a live e2e stack with VCR off. Don't hardcode `record_mode='none'`; if a test only works against its recorded cassette, that's a bug in the test.
- **Never `cp` a cassette from a sibling test, never hand-edit cassette bytes.** Re-record against a live stack.
Expand Down Expand Up @@ -46,6 +46,12 @@ pytest tests/cli_test.py::<Class>::<test> # records against whatever stack you

Point your environment at a live e2e stack, run the test, and commit the freshly recorded `.vcr` (+ updated `.click`). The deletion is what makes VCR record.

## Style 3 — formatter unit tests

For **rendering logic with no command-tree behaviour** — which labelled line a given field set produces — construct the formatter directly (`TextOutput(color=False)`) and call the resource method with an SDK resource built from a literal dict, asserting on the returned lines (`write=False`, no stream, no cassette). Example: `tests/known_good_field_test.py` renders `ArtifactInstance`s that differ only in `state` / `known_good` and asserts which Detections/Status line comes out. This is the right choice when the branch matrix is wide and every branch is a function of the resource's fields — a cassette per branch would mean recording a server state that only the formatter cares about.

Use it **only** for that. Argument parsing, SDK calls, generator consumption, `ctx.obj` wiring and exit codes are command behaviour: a formatter unit test can't observe them, so those need Style 1 or Style 2. A command whose rendering is covered by Style 3 still needs at least one `CliRunner` test proving the command reaches the formatter at all.

## What to test for a new command

1. The command parses its arguments and calls the expected SDK method with the expected arguments.
Expand Down
13 changes: 13 additions & 0 deletions specs/05-sdk-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,19 @@ When a CLI feature needs an SDK surface that doesn't exist yet:
- The pin lives in `pyproject.toml` `dependencies` (`polyswarm_api>=…`). Floor it at the lowest SDK version exposing everything the CLI uses; cap it below the next known-incompatible major when one is anticipated.
- The CLI is **sync-only** — it imports `polyswarm_api.api.PolyswarmAPI`, never `polyswarm_api.aio`. Don't add the `polyswarm_api[async]` extra.
- Bumping the pin is a normal code change; bumping the CLI's *own* version is a release step (`AGENTS.md` §Gitflow). They're unrelated.
- There is **no lock file / compiled requirements** to keep in step: `pyproject.toml` is the only place the SDK version is expressed, and CI installs the SDK straight from the SDK repo's branch archive (see §Coordinated changes). A pin change is a one-file change *in this repo*, but it is not free of interactions — see below.
- **The floor must be satisfied by the SDK archive CI installs, and by PyPI.** CI installs the archive build and *then* runs `pip install .[tests]`; if the archive's declared version is below the floor, that second install silently pulls a newer SDK from PyPI **over** the archive build, and CI stops testing the SDK branch at all — the mechanism §Coordinated changes rests on, defeated with no error. Symmetrically, a floor above the newest **published** version breaks `pip install polyswarm-cli` for every consumer the moment it reaches `master`. So a floor bump has two preconditions: the version is on PyPI, and the SDK's `develop` declares at least that version.

**Read the declared version off the archive's own tree, and mind pre-release suffixes.** PEP 440 orders `4.2.0.dev1 < 4.2.0`, so a `develop` head carrying a dev suffix (the SDK's `pyproject.toml` has a `[tool.bumpversion.parts.dev]`) would *not* satisfy a `>=4.2.0` floor even though it looks like 4.2.0 — and the archive build would be silently replaced from PyPI. Check the version string in the SDK branch's `pyproject.toml` / `__init__.py`, not the last release tag. For the current floor both were read from `origin/develop`: `version = "4.2.0"` and `__version__ = '4.2.0'`, no suffix.

### Current floor — `polyswarm_api>=4.2.0`

Two behaviours the CLI relies on only exist from **4.2.0**; on 4.1.0 both fail *silently*, which is why the floor is a hard requirement rather than a preference:

1. **`llm_report_create` sends the client's community.** 4.2.0 passes `community=self.community` when it builds the report resource; 4.1.0 omits it. `report llm-create` (`client/report.py`) supplies no community of its own — it relies entirely on the client's — so on 4.1.0 a report requested for a sample in a private community is created without one. No error, wrong resource.
2. **A streaming download answered `204 No Content` raises `NoResultsException`.** The streaming path bypasses `parse_response`, so the 204 has to be raised by the session itself; 4.2.0 does that, 4.1.0 has no such raise anywhere in its session. The CLI's `download` commands depend on it for the no-results **exit code `1`** (§No-results signalling); against 4.1.0 an empty response reads as a successful download and exits `0`.

The known-good rendering attributes (`ArtifactInstance.state`, `.known_good`/`.known_good_sources`, read by `formatters/text.py` — see [`03-formatters.md`](./03-formatters.md) §Known-good artifact instances) ship in **4.1.0**, so they are *not* what sets the floor; they are simply covered by it.

## Worked example — the httpx SDK migration

Expand Down
2 changes: 1 addition & 1 deletion src/polyswarm/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '4.2.1'
__version__ = '4.3.0'
Loading
Loading