mythos-sdk 0.1.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.

Potentially problematic release.


This version of mythos-sdk might be problematic. Click here for more details.

@@ -0,0 +1,35 @@
1
+ # Node
2
+ node_modules/
3
+ dist/
4
+ *.tsbuildinfo
5
+ .npm
6
+
7
+ # Python
8
+ __pycache__/
9
+ *.py[cod]
10
+ *.egg-info/
11
+ dist/
12
+ build/
13
+ .venv/
14
+ venv/
15
+ .pytest_cache/
16
+ .mypy_cache/
17
+ .ruff_cache/
18
+
19
+ # Env
20
+ .env
21
+ .env.local
22
+
23
+ # IDE
24
+ .vscode/
25
+ .idea/
26
+ *.swp
27
+
28
+ # OS
29
+ .DS_Store
30
+ Thumbs.db
31
+
32
+ # Coverage
33
+ coverage/
34
+ .coverage
35
+ htmlcov/
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: mythos-sdk
3
+ Version: 0.1.0
4
+ Summary: Official Mythos SDK for Python — launch token verification, usage reporting, and handshake
5
+ Project-URL: Homepage, https://github.com/Mythoswork/mythos-sdk
6
+ Project-URL: Repository, https://github.com/Mythoswork/mythos-sdk
7
+ License: Apache-2.0
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: httpx>=0.27.0
10
+ Requires-Dist: python-jose[cryptography]<4,>=3.3.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: cryptography>=42.0; extra == 'dev'
13
+ Requires-Dist: fastapi[standard]>=0.100.0; extra == 'dev'
14
+ Requires-Dist: httpx>=0.27.0; extra == 'dev'
15
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
16
+ Requires-Dist: pytest>=8.0; extra == 'dev'
17
+ Provides-Extra: fastapi
18
+ Requires-Dist: fastapi>=0.100.0; extra == 'fastapi'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # mythos-sdk
22
+
23
+ Official Mythos SDK for Python — launch token verification, usage reporting, and handshake.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install mythos-sdk[fastapi]
29
+ ```
30
+
31
+ ## Quick start
32
+
33
+ ```python
34
+ from fastapi import FastAPI, Depends
35
+ from mythos_sdk import require_launch_token, report_usage, create_handshake_router
36
+
37
+ app = FastAPI()
38
+
39
+ # Handshake endpoint — Mythos pings this before publishing your listing
40
+ app.include_router(create_handshake_router())
41
+
42
+ # Protected route — verifies and consumes the launch token automatically
43
+ @app.get("/dashboard")
44
+ async def dashboard(session = Depends(require_launch_token())):
45
+ # session = MythosSession(user_id, email, display_name, listing_id, session_jti)
46
+ await report_usage(session.session_jti, credits=1, reason="page-view")
47
+ return {"ok": True}
48
+ ```
49
+
50
+ ## Environment variables
51
+
52
+ | Variable | Required | Default | Description |
53
+ |----------|----------|---------|-------------|
54
+ | `MYTHOS_LISTING_ID` | Yes* | — | Your listing ID |
55
+ | `MYTHOS_LISTING_IDS` | Yes* | — | Comma-separated listing IDs (overrides above) |
56
+ | `MYTHOS_API_URL` | No | `https://api.mythos.work` | API base URL override |
57
+
58
+ *One of `MYTHOS_LISTING_ID` or `MYTHOS_LISTING_IDS` is required.
59
+
60
+ ## API
61
+
62
+ ### `require_launch_token()`
63
+
64
+ FastAPI dependency. Verifies the RS256 launch token from `?lt=`, enforces single-use semantics, and returns a `MythosSession`. Returns `401` if the token is missing, invalid, or already consumed.
65
+
66
+ ### `report_usage(session_jti, *, credits, reason=None)`
67
+
68
+ Reports credit consumption against a session. Call after delivering value to the user.
69
+
70
+ ### `create_handshake_router()`
71
+
72
+ Returns a FastAPI `APIRouter` that mounts `GET /.well-known/mythos-handshake`. Mythos calls this endpoint during listing publish to confirm the SDK is installed and reachable.
73
+
74
+ ### `verify_launch_token(token)`
75
+
76
+ Low-level token verifier. Returns the decoded payload. Use `require_launch_token()` dependency instead for most cases.
77
+
78
+ ## Security
79
+
80
+ - Tokens verified via RS256 against the Mythos JWKS endpoint
81
+ - `alg: none` rejected as a hard block
82
+ - Single-use enforcement is non-skippable (ADR-0003)
83
+ - JWKS keys cached 10 minutes with automatic re-fetch on key rotation
84
+
85
+ ## License
86
+
87
+ Apache-2.0 — see [LICENSE](../../LICENSE).
@@ -0,0 +1,67 @@
1
+ # mythos-sdk
2
+
3
+ Official Mythos SDK for Python — launch token verification, usage reporting, and handshake.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install mythos-sdk[fastapi]
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```python
14
+ from fastapi import FastAPI, Depends
15
+ from mythos_sdk import require_launch_token, report_usage, create_handshake_router
16
+
17
+ app = FastAPI()
18
+
19
+ # Handshake endpoint — Mythos pings this before publishing your listing
20
+ app.include_router(create_handshake_router())
21
+
22
+ # Protected route — verifies and consumes the launch token automatically
23
+ @app.get("/dashboard")
24
+ async def dashboard(session = Depends(require_launch_token())):
25
+ # session = MythosSession(user_id, email, display_name, listing_id, session_jti)
26
+ await report_usage(session.session_jti, credits=1, reason="page-view")
27
+ return {"ok": True}
28
+ ```
29
+
30
+ ## Environment variables
31
+
32
+ | Variable | Required | Default | Description |
33
+ |----------|----------|---------|-------------|
34
+ | `MYTHOS_LISTING_ID` | Yes* | — | Your listing ID |
35
+ | `MYTHOS_LISTING_IDS` | Yes* | — | Comma-separated listing IDs (overrides above) |
36
+ | `MYTHOS_API_URL` | No | `https://api.mythos.work` | API base URL override |
37
+
38
+ *One of `MYTHOS_LISTING_ID` or `MYTHOS_LISTING_IDS` is required.
39
+
40
+ ## API
41
+
42
+ ### `require_launch_token()`
43
+
44
+ FastAPI dependency. Verifies the RS256 launch token from `?lt=`, enforces single-use semantics, and returns a `MythosSession`. Returns `401` if the token is missing, invalid, or already consumed.
45
+
46
+ ### `report_usage(session_jti, *, credits, reason=None)`
47
+
48
+ Reports credit consumption against a session. Call after delivering value to the user.
49
+
50
+ ### `create_handshake_router()`
51
+
52
+ Returns a FastAPI `APIRouter` that mounts `GET /.well-known/mythos-handshake`. Mythos calls this endpoint during listing publish to confirm the SDK is installed and reachable.
53
+
54
+ ### `verify_launch_token(token)`
55
+
56
+ Low-level token verifier. Returns the decoded payload. Use `require_launch_token()` dependency instead for most cases.
57
+
58
+ ## Security
59
+
60
+ - Tokens verified via RS256 against the Mythos JWKS endpoint
61
+ - `alg: none` rejected as a hard block
62
+ - Single-use enforcement is non-skippable (ADR-0003)
63
+ - JWKS keys cached 10 minutes with automatic re-fetch on key rotation
64
+
65
+ ## License
66
+
67
+ Apache-2.0 — see [LICENSE](../../LICENSE).
@@ -0,0 +1,18 @@
1
+ from .errors import InsufficientFundsError, MythosError, SessionNotFoundError
2
+ from .handshake import create_handshake_router, handshake_router
3
+ from .middleware import require_launch_token
4
+ from .report_usage import report_usage
5
+ from .types import MythosSession
6
+ from .verify import verify_launch_token
7
+
8
+ __all__ = [
9
+ "verify_launch_token",
10
+ "require_launch_token",
11
+ "report_usage",
12
+ "handshake_router",
13
+ "create_handshake_router",
14
+ "MythosSession",
15
+ "MythosError",
16
+ "InsufficientFundsError",
17
+ "SessionNotFoundError",
18
+ ]
@@ -0,0 +1,37 @@
1
+ import uuid
2
+ from typing import Any
3
+
4
+ import httpx
5
+
6
+ from .config import load_config
7
+ from .errors import InsufficientFundsError, SessionNotFoundError
8
+
9
+
10
+ async def consume_session(jti: str) -> httpx.Response:
11
+ config = load_config()
12
+ async with httpx.AsyncClient() as client:
13
+ return await client.post(
14
+ f"{config.api_url}/api/apps/sessions/{jti}/consume",
15
+ json={},
16
+ )
17
+
18
+
19
+ async def meter_session(jti: str, credits: int, reason: str | None = None) -> None:
20
+ config = load_config()
21
+ # charge_id is a per-call idempotency key required by the backend's SQS metering
22
+ # job dedup (see backend docs/migrations) — generated here, not caller-supplied.
23
+ body: dict[str, Any] = {"credits": credits, "charge_id": str(uuid.uuid4())}
24
+ if reason is not None:
25
+ body["reason"] = reason
26
+
27
+ async with httpx.AsyncClient() as client:
28
+ resp = await client.post(
29
+ f"{config.api_url}/api/apps/sessions/{jti}/meter",
30
+ json=body,
31
+ )
32
+
33
+ if resp.status_code == 402:
34
+ raise InsufficientFundsError()
35
+ if resp.status_code == 404:
36
+ raise SessionNotFoundError(jti)
37
+ resp.raise_for_status()
@@ -0,0 +1,28 @@
1
+ import os
2
+ from dataclasses import dataclass
3
+
4
+ DEFAULT_API_URL = "https://api.mythos.work"
5
+
6
+
7
+ @dataclass
8
+ class MythosConfig:
9
+ listing_ids: list[str]
10
+ api_url: str
11
+
12
+
13
+ def load_config() -> MythosConfig:
14
+ api_url = os.environ.get("MYTHOS_API_URL", DEFAULT_API_URL)
15
+ multi = os.environ.get("MYTHOS_LISTING_IDS")
16
+ single = os.environ.get("MYTHOS_LISTING_ID")
17
+
18
+ if multi:
19
+ listing_ids = [lid.strip() for lid in multi.split(",") if lid.strip()]
20
+ elif single:
21
+ listing_ids = [single]
22
+ else:
23
+ listing_ids = []
24
+
25
+ if not listing_ids:
26
+ raise RuntimeError("MYTHOS_LISTING_ID or MYTHOS_LISTING_IDS env var is required")
27
+
28
+ return MythosConfig(listing_ids=listing_ids, api_url=api_url)
@@ -0,0 +1,14 @@
1
+ class MythosError(Exception):
2
+ def __init__(self, message: str, code: str) -> None:
3
+ super().__init__(message)
4
+ self.code = code
5
+
6
+
7
+ class InsufficientFundsError(MythosError):
8
+ def __init__(self) -> None:
9
+ super().__init__("Insufficient funds in wallet", "INSUFFICIENT_FUNDS")
10
+
11
+
12
+ class SessionNotFoundError(MythosError):
13
+ def __init__(self, jti: str) -> None:
14
+ super().__init__(f"Session not found: {jti}", "SESSION_NOT_FOUND")
@@ -0,0 +1,54 @@
1
+ import logging
2
+ import os
3
+
4
+ from fastapi import APIRouter, Request
5
+ from fastapi.responses import JSONResponse
6
+ from jose import jwt
7
+ from jose.exceptions import ExpiredSignatureError, JOSEError, JWTError
8
+
9
+ from .jwks_cache import get_jwks, get_jwks_with_kid_fallback
10
+
11
+ _logger = logging.getLogger(__name__)
12
+
13
+ SDK_VERSION = "0.1.0"
14
+ _DEFAULT_API_URL = "https://api.mythos.work"
15
+ # Tokens in this SDK carry no aud/iss claims.
16
+ _DECODE_OPTIONS = {"verify_aud": False, "verify_iss": False}
17
+
18
+
19
+ async def _validate_handshake_token(token: str) -> None:
20
+ api_url = os.environ.get("MYTHOS_API_URL", _DEFAULT_API_URL)
21
+ jwks = await get_jwks(api_url)
22
+ try:
23
+ payload = jwt.decode(token, jwks, algorithms=["RS256"], options=_DECODE_OPTIONS)
24
+ except ExpiredSignatureError:
25
+ raise
26
+ except JWTError:
27
+ jwks = await get_jwks_with_kid_fallback(api_url)
28
+ payload = jwt.decode(token, jwks, algorithms=["RS256"], options=_DECODE_OPTIONS)
29
+
30
+ if payload.get("purpose") != "handshake-check":
31
+ raise JWTError("Token purpose is not handshake-check")
32
+
33
+
34
+ def create_handshake_router() -> APIRouter:
35
+ router = APIRouter()
36
+
37
+ @router.get("/.well-known/mythos-handshake")
38
+ async def mythos_handshake(request: Request) -> JSONResponse:
39
+ token = request.query_params.get("lt")
40
+ if not token:
41
+ return JSONResponse({"error": "Missing launch token"}, status_code=401)
42
+ try:
43
+ await _validate_handshake_token(token)
44
+ except JOSEError:
45
+ return JSONResponse({"error": "Invalid launch token"}, status_code=401)
46
+ except Exception:
47
+ _logger.exception("Unexpected error in handshake endpoint")
48
+ return JSONResponse({"error": "Service unavailable"}, status_code=503)
49
+ return JSONResponse({"ok": True, "sdk_version": SDK_VERSION})
50
+
51
+ return router
52
+
53
+
54
+ handshake_router = create_handshake_router()
@@ -0,0 +1,40 @@
1
+ import time
2
+ from typing import Any
3
+
4
+ import httpx
5
+
6
+ CACHE_TTL = 600 # 10 minutes
7
+
8
+ _cache: dict[str, Any] | None = None
9
+ _fetched_at: float = 0.0
10
+
11
+
12
+ def _is_stale() -> bool:
13
+ return _cache is None or time.monotonic() - _fetched_at > CACHE_TTL
14
+
15
+
16
+ async def _fetch_jwks(api_url: str) -> dict[str, Any]:
17
+ global _cache, _fetched_at
18
+ async with httpx.AsyncClient() as client:
19
+ resp = await client.get(f"{api_url}/.well-known/jwks.json")
20
+ resp.raise_for_status()
21
+ data: dict[str, Any] = resp.json()
22
+ _cache = data
23
+ _fetched_at = time.monotonic()
24
+ return data
25
+
26
+
27
+ async def get_jwks(api_url: str, force_refresh: bool = False) -> dict[str, Any]:
28
+ if not force_refresh and not _is_stale() and _cache is not None:
29
+ return _cache
30
+ return await _fetch_jwks(api_url)
31
+
32
+
33
+ async def get_jwks_with_kid_fallback(api_url: str) -> dict[str, Any]:
34
+ return await get_jwks(api_url, force_refresh=True)
35
+
36
+
37
+ def clear_cache() -> None:
38
+ global _cache, _fetched_at
39
+ _cache = None
40
+ _fetched_at = 0.0
@@ -0,0 +1,27 @@
1
+ from fastapi import HTTPException, Query
2
+
3
+ from .api_client import consume_session
4
+ from .types import MythosSession
5
+ from .verify import verify_launch_token
6
+
7
+
8
+ async def require_launch_token(lt: str = Query(..., alias="lt")) -> MythosSession:
9
+ try:
10
+ session = await verify_launch_token(lt)
11
+ except Exception:
12
+ raise HTTPException(status_code=401, detail="Invalid launch token")
13
+
14
+ try:
15
+ resp = await consume_session(session.sessionJti)
16
+ except Exception:
17
+ # Network error / unreachable consume endpoint — fail closed, never grant
18
+ # single-use access without a confirmed consume.
19
+ raise HTTPException(status_code=503, detail="Could not verify session")
20
+
21
+ if resp.status_code == 409:
22
+ raise HTTPException(status_code=401, detail="Token already consumed")
23
+ if not (200 <= resp.status_code < 300):
24
+ # Any other non-2xx (500, 503, ...) is unconfirmed — fail closed.
25
+ raise HTTPException(status_code=503, detail="Could not verify session")
26
+
27
+ return session
@@ -0,0 +1,5 @@
1
+ from .api_client import meter_session
2
+
3
+
4
+ async def report_usage(jti: str, credits: int, reason: str | None = None) -> None:
5
+ await meter_session(jti, credits, reason)
@@ -0,0 +1,10 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class MythosSession:
6
+ userId: str
7
+ email: str
8
+ displayName: str
9
+ listingId: str
10
+ sessionJti: str
@@ -0,0 +1,69 @@
1
+ from typing import Any
2
+
3
+ from jose import jwt, JWTError
4
+ from jose.exceptions import ExpiredSignatureError, JWTClaimsError
5
+
6
+ from .config import load_config
7
+ from .jwks_cache import get_jwks, get_jwks_with_kid_fallback
8
+ from .types import MythosSession
9
+
10
+ ALGORITHMS = ["RS256"]
11
+ _DECODE_OPTIONS = {"verify_aud": False}
12
+
13
+ # Matches backend's HANDSHAKE_ISS_CLAIM constant — the platform issuer is a
14
+ # fixed identifier, not the API URL (which varies by environment).
15
+ MYTHOS_ISSUER = "mythos"
16
+
17
+
18
+ def _build_session(payload: dict[str, Any]) -> MythosSession:
19
+ return MythosSession(
20
+ userId=payload["sub"],
21
+ email=payload["email"],
22
+ displayName=payload["displayName"],
23
+ listingId=payload["listingId"],
24
+ sessionJti=payload["jti"],
25
+ )
26
+
27
+
28
+ def _validate_audience(payload: dict[str, Any], listing_ids: list[str]) -> None:
29
+ """Check all aud elements for membership — avoids aud[0]-only ordering bug."""
30
+ aud = payload.get("aud")
31
+ if aud is None:
32
+ raise JWTClaimsError("Missing audience claim")
33
+ aud_values = aud if isinstance(aud, list) else [aud]
34
+ if not any(a in listing_ids for a in aud_values):
35
+ raise JWTClaimsError("Invalid audience")
36
+
37
+
38
+ async def verify_launch_token(token: str) -> MythosSession:
39
+ config = load_config()
40
+
41
+ jwks = await get_jwks(config.api_url)
42
+ try:
43
+ payload = jwt.decode(
44
+ token,
45
+ jwks,
46
+ algorithms=ALGORITHMS,
47
+ issuer=MYTHOS_ISSUER,
48
+ options=_DECODE_OPTIONS,
49
+ )
50
+ except JWTError as e:
51
+ if isinstance(e, (ExpiredSignatureError, JWTClaimsError)):
52
+ raise
53
+ # python-jose 3.x raises a bare JWTError with this message for signature
54
+ # failures (version pinned <4 in pyproject.toml so this text stays stable).
55
+ # A bad signature is not a kid miss — fail immediately, don't re-fetch JWKS.
56
+ if "Signature verification failed" in str(e):
57
+ raise
58
+ # possible kid miss — re-fetch once
59
+ jwks = await get_jwks_with_kid_fallback(config.api_url)
60
+ payload = jwt.decode(
61
+ token,
62
+ jwks,
63
+ algorithms=ALGORITHMS,
64
+ issuer=MYTHOS_ISSUER,
65
+ options=_DECODE_OPTIONS,
66
+ )
67
+
68
+ _validate_audience(payload, config.listing_ids)
69
+ return _build_session(payload)
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [dependency-groups]
6
+ dev = [
7
+ "pytest>=8.0",
8
+ "pytest-asyncio>=0.23",
9
+ "cryptography>=42.0",
10
+ "fastapi[standard]>=0.100.0",
11
+ "httpx>=0.27.0",
12
+ ]
13
+
14
+ [project]
15
+ name = "mythos-sdk"
16
+ version = "0.1.0"
17
+ description = "Official Mythos SDK for Python — launch token verification, usage reporting, and handshake"
18
+ readme = "README.md"
19
+ license = { text = "Apache-2.0" }
20
+ requires-python = ">=3.11"
21
+ dependencies = [
22
+ "python-jose[cryptography]>=3.3.0,<4",
23
+ "httpx>=0.27.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/Mythoswork/mythos-sdk"
28
+ Repository = "https://github.com/Mythoswork/mythos-sdk"
29
+
30
+ [project.optional-dependencies]
31
+ fastapi = ["fastapi>=0.100.0"]
32
+ dev = [
33
+ "pytest>=8.0",
34
+ "pytest-asyncio>=0.23",
35
+ "cryptography>=42.0",
36
+ "fastapi[standard]>=0.100.0",
37
+ "httpx>=0.27.0",
38
+ ]
39
+
40
+ [tool.pytest.ini_options]
41
+ asyncio_mode = "auto"
42
+
43
+ [tool.mypy]
44
+ python_version = "3.11"
45
+
46
+ [[tool.mypy.overrides]]
47
+ # python-jose ships no type stubs and types-python-jose is incomplete/unmaintained.
48
+ module = ["jose.*"]
49
+ ignore_missing_imports = true
@@ -0,0 +1,41 @@
1
+ import base64
2
+ import pytest
3
+ from cryptography.hazmat.primitives.asymmetric import rsa
4
+ from cryptography.hazmat.primitives import serialization
5
+
6
+
7
+ def _int_to_base64url(n: int) -> str:
8
+ length = (n.bit_length() + 7) // 8
9
+ return base64.urlsafe_b64encode(n.to_bytes(length, "big")).rstrip(b"=").decode()
10
+
11
+
12
+ @pytest.fixture(autouse=True)
13
+ def set_env(monkeypatch):
14
+ monkeypatch.setenv("MYTHOS_LISTING_ID", "listing-abc")
15
+ monkeypatch.setenv("MYTHOS_API_URL", "https://api.mythos.work")
16
+ monkeypatch.delenv("MYTHOS_LISTING_IDS", raising=False)
17
+
18
+
19
+ @pytest.fixture(scope="session")
20
+ def rsa_key_pair():
21
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
22
+ private_pem = private_key.private_bytes(
23
+ serialization.Encoding.PEM,
24
+ serialization.PrivateFormat.TraditionalOpenSSL,
25
+ serialization.NoEncryption(),
26
+ )
27
+ public_key = private_key.public_key()
28
+ public_pem = public_key.public_bytes(
29
+ serialization.Encoding.PEM,
30
+ serialization.PublicFormat.SubjectPublicKeyInfo,
31
+ )
32
+ pub_numbers = public_key.public_numbers()
33
+ jwk = {
34
+ "kty": "RSA",
35
+ "use": "sig",
36
+ "alg": "RS256",
37
+ "kid": "test-kid",
38
+ "n": _int_to_base64url(pub_numbers.n),
39
+ "e": _int_to_base64url(pub_numbers.e),
40
+ }
41
+ return {"private": private_pem, "public": public_pem, "private_key": private_key, "jwk": jwk}
@@ -0,0 +1,24 @@
1
+ import re
2
+ from unittest.mock import AsyncMock, patch
3
+
4
+ import httpx
5
+
6
+ from mythos_sdk.api_client import meter_session
7
+
8
+ UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
9
+
10
+
11
+ async def test_meter_session_sends_fresh_uuid_charge_id():
12
+ response = httpx.Response(200, json={}, request=httpx.Request("POST", "https://api.mythos.work/"))
13
+ mock_post = AsyncMock(return_value=response)
14
+ with patch("httpx.AsyncClient.post", mock_post):
15
+ await meter_session("jti-001", 5, "page-view")
16
+ await meter_session("jti-001", 5, "page-view")
17
+
18
+ bodies = [call.kwargs["json"] for call in mock_post.await_args_list]
19
+
20
+ assert UUID_RE.match(bodies[0]["charge_id"])
21
+ assert UUID_RE.match(bodies[1]["charge_id"])
22
+ assert bodies[0]["charge_id"] != bodies[1]["charge_id"]
23
+ assert bodies[0]["credits"] == 5
24
+ assert bodies[0]["reason"] == "page-view"