# VenomQA — Complete LLM Context Document
# Paste this into any AI assistant before asking for help writing VenomQA tests.
# Generated by: venomqa llm-docs

## What VenomQA is

VenomQA is an autonomous API QA agent. Instead of writing linear test scripts,
you define:
  - Actions  — callables that call your API
  - Invariants — rules that must always hold after every action

VenomQA then exhaustively explores every reachable sequence of actions using
BFS (breadth-first search), checkpointing and rolling back state between
branches. Every path through the action graph starts from a clean state.

---

## Installation

    python3 -m venv .venv
    source .venv/bin/activate
    pip install venomqa

Python 3.10+ required.

---

## Core imports (v1 API)

    from venomqa.v1 import (
        Action,
        ActionResult,
        Invariant,
        Violation,
        Severity,
        InvariantTiming,
        Agent,
        World,
        BFS, DFS, Random, CoverageGuided, Weighted,
        ExplorationResult,
    )
    from venomqa.v1.adapters.http import HttpClient
    from venomqa.v1.adapters.postgres import PostgresAdapter   # optional
    from venomqa.v1.adapters.redis import RedisAdapter         # optional
    from venomqa.v1.reporters.console import ConsoleReporter
    from venomqa.v1.reporters.html_trace import HTMLTraceReporter
    from venomqa.v1.reporters.json import JSONReporter

---

## Action — the ONLY correct signature

Actions always receive TWO arguments: (api, context), in that order.

    def my_action(api, context):
        # api      — HttpClient, has .get() .post() .put() .patch() .delete()
        # context  — Context object, NOT a dict. Use .get()/.set(), not ["key"]
        ...

WRONG (do not write):
    def bad(ctx, api):    # reversed order — will silently break
    def bad(context):     # missing api — will crash
    def bad(api, ctx):    # 'ctx' name is fine but order must be api-first

The framework auto-detects whether your function has 2 parameters and passes
context only if it does. A 1-parameter function receives only api.

### Action examples

    # No shared state needed
    def health_check(api, context):
        return api.get("/health")

    # Write to context so later actions can read it
    def create_user(api, context):
        resp = api.post("/users", json={"name": "Alice"})
        context.set("user_id", resp.json()["id"])
        return resp

    # Read from context written by a previous action
    def get_user(api, context):
        user_id = context.get("user_id")
        return api.get(f"/users/{user_id}")

    # Guard: skip if precondition not met
    def delete_user(api, context):
        if not context.has("user_id"):
            return api.get("/noop")   # or raise, or return early
        return api.delete(f"/users/{context.get('user_id')}")

### Wrapping a function as an Action

    Action(
        name="create_user",         # unique string identifier
        execute=create_user,        # the function above
        description="Create a user account",   # optional, human-readable
        tags=["users", "write"],    # optional, for filtering
        expected_status=[201],      # optional, checks response status
        expect_failure=False,       # set True to expect 4xx/5xx
    )

---

## Context — shared state between actions

Context is NOT a dict. It uses explicit .get() / .set() methods.

    context.set("key", value)           # store any value
    value = context.get("key")          # returns None if missing
    value = context.get("key", default) # with default
    exists = context.has("key")         # bool
    context.delete("key")               # remove key
    all_keys = context.keys()           # list of all keys
    context.update({"a": 1, "b": 2})    # bulk set

DO NOT USE:
    context["key"]          # TypeError — not subscriptable
    context["key"] = val    # TypeError — not subscriptable

Scoped contexts (namespace isolation):
    user_ctx = context.scope("user")
    user_ctx.set("id", 123)     # stores as "user.id"
    user_ctx.get("id")          # reads "user.id"

---

## Invariant — the ONLY correct signature

Invariants receive ONE argument: the World object.

    def my_invariant(world):
        # world.context — the shared Context (read state set by actions)
        # world.api     — the HttpClient (can make live API calls)
        # world.systems — dict of registered rollbackable systems
        return True   # True = pass, False = violation

WRONG (do not write):
    def bad(state, ctx):   # wrong — this was the old v0 signature
    def bad(state):        # wrong
    def bad():             # wrong

### Invariant constructor

    Invariant(
        name="my_invariant",            # unique string
        check=my_invariant,             # the function above
        message="Explanation of rule",  # shown on violation — use 'message', NOT 'description'
        severity=Severity.CRITICAL,     # CRITICAL, HIGH, MEDIUM (default), LOW
        timing=InvariantTiming.POST_ACTION,  # POST_ACTION (default), PRE_ACTION, BOTH
    )

### Invariant examples

    # Check context has required keys
    def user_is_set(world):
        return world.context.has("user_id")

    # Check a value stored by an action
    def refund_within_bounds(world):
        refund  = world.context.get("refund_amount") or 0
        payment = world.context.get("payment_amount") or 0
        return refund <= payment

    # Make a live API call inside the invariant
    def list_shows_only_open(world):
        resp = world.api.get("/issues?state=open")
        issues = resp.json()
        return all(i["state"] == "open" for i in issues)

    # Example with severity + timing
    Invariant(
        name="refund_within_bounds",
        check=refund_within_bounds,
        message="Refunded amount must not exceed original payment",
        severity=Severity.CRITICAL,
    )

---

## World — the execution sandbox

    World(
        api=HttpClient("http://localhost:8000"),   # required
        systems={                                   # optional
            "db":    PostgresAdapter("postgresql://user:pass@localhost/mydb"),
            "cache": RedisAdapter("redis://localhost:6379"),
        },
    )

Accessing context from outside actions/invariants:
    world.context.set("stripe", stripe_client)   # inject external clients
    world.context.get("stripe")                  # read in actions

---

## Agent — runs the exploration

    agent = Agent(
        world=world,
        actions=[...],        # list[Action]
        invariants=[...],     # list[Invariant]
        strategy=BFS(),       # exploration strategy
        max_steps=500,        # hard stop (default 1000)
    )

    result = agent.explore()  # returns ExplorationResult

---

## Exploration strategies

    BFS()             # breadth-first search — best for finding bugs, explores shortest paths first
    DFS()             # depth-first search — finds deep sequences faster
    Random()          # random walk — useful for fuzzing
    CoverageGuided()  # prioritises states not yet seen

---

## ExplorationResult — reading results

    result.states_visited    # int — total distinct states reached
    result.transitions_taken # int — total action executions
    result.duration_ms       # float — wall-clock time
    result.violations        # list[Violation]
    result.graph             # Graph object with full transition history

    for v in result.violations:
        v.invariant_name      # str
        v.message             # str — the invariant's message field
        v.severity            # Severity enum
        v.severity.value      # "critical" | "high" | "medium" | "low"
        v.reproduction_path   # list[Transition] — how to reach this state
        v.action              # Action that triggered the violation
        v.action_result       # ActionResult (request + response)

---

## Reporters

    # Console (prints to stdout)
    from venomqa.v1.reporters.console import ConsoleReporter
    ConsoleReporter().report(result)

    # HTML (D3 force graph — returns str, write to file yourself)
    from venomqa.v1.reporters.html_trace import HTMLTraceReporter
    html_str = HTMLTraceReporter().report(result)
    with open("trace.html", "w") as f:
        f.write(html_str)

    # JSON
    from venomqa.v1.reporters.json import JSONReporter
    json_str = JSONReporter().report(result)

DO NOT call HTMLTraceReporter().report(result, path="trace.html") —
the path= kwarg does not exist. The method only takes result and returns a string.

---

## HttpClient — available methods

    api.get(path, **kwargs)
    api.post(path, json=None, data=None, **kwargs)
    api.put(path, json=None, **kwargs)
    api.patch(path, json=None, **kwargs)
    api.delete(path, **kwargs)

All return an httpx.Response — use .json(), .text, .status_code, .ok etc.

Headers / auth:
    HttpClient("http://localhost:8000", headers={"Authorization": "Bearer token"})

---

## Rollbackable adapters

PostgreSQL (uses SAVEPOINT — entire run is one uncommitted transaction):
    PostgresAdapter("postgresql://user:pass@localhost/mydb")

Redis (DUMP + FLUSHALL + RESTORE per rollback):
    RedisAdapter("redis://localhost:6379")

In-memory mocks (queue, mail, storage) — import from venomqa.v1.adapters.*

Custom mock server: subclass MockHTTPServer, implement 3 methods:
    get_state_snapshot() -> dict
    rollback_from_snapshot(snapshot: dict) -> None
    observe_from_state(state_snapshot: dict) -> Observation

---

## Common mistakes to avoid

1. Action signature reversed:
   BAD:  def my_action(ctx, api)
   GOOD: def my_action(api, context)

2. Dict-style context access:
   BAD:  context["key"] = val   → TypeError
   GOOD: context.set("key", val)

3. Invariant receives World, not (state, ctx):
   BAD:  def check(state, ctx)
   GOOD: def check(world)

4. Invariant uses 'description=' instead of 'message=':
   BAD:  Invariant(name=..., check=..., description="...")   → TypeError
   GOOD: Invariant(name=..., check=..., message="...")

5. HTMLTraceReporter passes path= kwarg:
   BAD:  html.report(result, path="out.html")   → TypeError
   GOOD: open("out.html","w").write(html.report(result))

6. Using python instead of python3 on macOS:
   BAD:  python main.py
   GOOD: python3 main.py

---

## Minimal working example (copy-paste ready)

    from venomqa.v1 import Action, Invariant, Agent, World, BFS, Severity
    from venomqa.v1.adapters.http import HttpClient

    def create_item(api, context):
        resp = api.post("/items", json={"name": "widget"})
        context.set("item_id", resp.json()["id"])
        return resp

    def list_items(api, context):
        resp = api.get("/items")
        context.set("items", resp.json())
        return resp

    def delete_item(api, context):
        item_id = context.get("item_id")
        if item_id is None:
            return api.get("/items")   # no-op if nothing to delete
        return api.delete(f"/items/{item_id}")

    def count_is_consistent(world):
        items = world.context.get("items") or []
        return isinstance(items, list)

    agent = Agent(
        world=World(api=HttpClient("http://localhost:8000")),
        actions=[
            Action(name="create_item", execute=create_item, expected_status=[201]),
            Action(name="list_items",  execute=list_items,  expected_status=[200]),
            Action(name="delete_item", execute=delete_item),
        ],
        invariants=[
            Invariant(
                name="list_is_always_a_list",
                check=count_is_consistent,
                message="GET /items must always return a JSON array",
                severity=Severity.CRITICAL,
            ),
        ],
        strategy=BFS(),
        max_steps=100,
    )

    result = agent.explore()
    for v in result.violations:
        print(f"[{v.severity.value.upper()}] {v.invariant_name}: {v.message}")
    print(f"Done. States={result.states_visited}, Violations={len(result.violations)}")

---

End of VenomQA LLM context document.
