Skip to content

Commit 1e6e043

Browse files
vdavezclaude
andcommitted
CLI: clarify simulate output; add fetch-sample and list-event-types
The simulate output's `sent_bytes` (length only) and `response_body` (the receiver's pong) were both confusing for devs who really want to see the canonical Tango shape they're driving their handler with: - `sent_bytes` (int) → `sent_payload` (the parsed JSON dict) - `response_body` → `receiver_response` (clarifies whose body it is) Two new subcommands close the discovery loop for devs building against the Tango API: - `tango webhooks fetch-sample [--event-type X]` — print the canonical sample payload Tango emits (read-only, no POST). Wraps the SDK's `get_webhook_sample_payload`. - `tango webhooks list-event-types` — list every event type Tango supports with descriptions. Wraps `list_webhook_event_types`. Together: `list-event-types` → pick one → `fetch-sample` to see the shape → `simulate --event-type X` to drive the handler with that shape, all without leaving the shell. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cf221e9 commit 1e6e043

3 files changed

Lines changed: 127 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- `tango.webhooks` subpackage with HMAC-SHA256 signing helpers (`verify_signature`, `generate_signature`, `parse_signature_header`) that mirror the canonical Tango server scheme byte-for-byte. Importable from a default `pip install tango-python` (pure stdlib).
1212
- `WebhookReceiver`: a stdlib-based local HTTP listener for development and integration tests. Verifies signatures, optionally forwards each delivery to a downstream URL, and records deliveries in memory for inspection. Usable as a context manager (`with WebhookReceiver(secret=...).run() as rx: ...`).
1313
- `tango.webhooks.simulate.deliver(...)`: locally sign and POST a payload to any URL — no Tango involvement. Useful for offline iteration on receiver code.
14-
- New `tango[webhooks]` extra (adds `click`) ships a `tango` console script with `webhooks listen|trigger|simulate` subcommands. Stripe-CLI-style developer ergonomics for testing webhook integrations.
14+
- New `tango[webhooks]` extra (adds `click`) ships a `tango` console script with `webhooks listen|trigger|simulate|fetch-sample|list-event-types` subcommands. Stripe-CLI-style developer ergonomics for testing webhook integrations: `fetch-sample` and `list-event-types` let devs discover canonical payload shapes without dropping into Python.
1515

1616
### Notes
1717
- Console script name `tango` may be revisited before the next release if it conflicts with sibling tooling (`tango-scripts` reuses the bare name).

tango/webhooks/cli.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,16 +202,69 @@ def simulate_cmd(
202202
{
203203
"status_code": result.status_code,
204204
"signature": f"sha256={result.signature}",
205-
"sent_bytes": len(result.sent_bytes),
206-
"response_body": result.response_body[:500],
205+
"sent_payload": payload,
206+
"receiver_response": result.response_body[:500],
207207
},
208208
indent=2,
209+
sort_keys=True,
209210
)
210211
)
211212
if result.status_code >= 400:
212213
raise SystemExit(1)
213214

214215

216+
@webhooks.command("fetch-sample")
217+
@click.option(
218+
"--event-type",
219+
default=None,
220+
help="If set, fetch the canonical sample for that event type. "
221+
"Otherwise return the full samples mapping for every event type.",
222+
)
223+
@click.option(
224+
"--api-key",
225+
envvar="TANGO_API_KEY",
226+
help="Tango API key (or TANGO_API_KEY).",
227+
)
228+
@click.option(
229+
"--base-url",
230+
envvar="TANGO_BASE_URL",
231+
default="https://tango.makegov.com",
232+
show_default=True,
233+
help="Tango base URL (or TANGO_BASE_URL).",
234+
)
235+
def fetch_sample_cmd(event_type: str | None, api_key: str | None, base_url: str) -> None:
236+
"""Print the canonical sample payload Tango emits for a given event type."""
237+
from tango import TangoClient
238+
239+
client = TangoClient(api_key=api_key, base_url=base_url)
240+
payload = client.get_webhook_sample_payload(event_type=event_type)
241+
click.echo(json.dumps(payload, indent=2, sort_keys=True))
242+
243+
244+
@webhooks.command("list-event-types")
245+
@click.option(
246+
"--api-key",
247+
envvar="TANGO_API_KEY",
248+
help="Tango API key (or TANGO_API_KEY).",
249+
)
250+
@click.option(
251+
"--base-url",
252+
envvar="TANGO_BASE_URL",
253+
default="https://tango.makegov.com",
254+
show_default=True,
255+
help="Tango base URL (or TANGO_BASE_URL).",
256+
)
257+
def list_event_types_cmd(api_key: str | None, base_url: str) -> None:
258+
"""List webhook event types Tango supports, with descriptions."""
259+
from tango import TangoClient
260+
261+
client = TangoClient(api_key=api_key, base_url=base_url)
262+
response = client.list_webhook_event_types()
263+
width = max((len(et.event_type) for et in response.event_types), default=0)
264+
for et in response.event_types:
265+
click.echo(f"{et.event_type:<{width}} {et.description}")
266+
267+
215268
def _print_delivery(delivery: Delivery) -> None:
216269
"""Default ``listen`` callback: write a one-line summary plus body."""
217270
summary = _summarize(delivery.body_json)

tests/test_webhooks_cli.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ def test_cli_help() -> None:
1717
assert "listen" in result.output
1818
assert "trigger" in result.output
1919
assert "simulate" in result.output
20+
assert "fetch-sample" in result.output
21+
assert "list-event-types" in result.output
2022

2123

2224
def test_cli_simulate_signs_and_posts(tmp_path: object) -> None:
@@ -43,6 +45,11 @@ def test_cli_simulate_signs_and_posts(tmp_path: object) -> None:
4345
body = json.loads(result.output)
4446
assert body["status_code"] == 200
4547
assert body["signature"].startswith("sha256=")
48+
# Output now includes the actual payload that was sent (the dev's
49+
# main artifact of interest), not just its byte length.
50+
assert isinstance(body["sent_payload"], dict)
51+
assert "events" in body["sent_payload"]
52+
assert body["receiver_response"] == '{"ok": true}'
4653

4754

4855
def test_cli_simulate_with_payload_file(tmp_path: object) -> None:
@@ -72,6 +79,70 @@ def test_cli_simulate_with_payload_file(tmp_path: object) -> None:
7279
assert rx.deliveries[0].body_json == payload
7380

7481

82+
def test_cli_fetch_sample_prints_payload() -> None:
83+
"""fetch-sample hits the SDK's get_webhook_sample_payload and pretty-prints."""
84+
from unittest.mock import Mock, patch
85+
86+
sample = {"events": [{"event_type": "entities.updated", "uei": "ABC"}]}
87+
mock_response = Mock()
88+
mock_response.status_code = 200
89+
mock_response.headers = {}
90+
mock_response.json.return_value = sample
91+
mock_response.raise_for_status = Mock()
92+
93+
runner = CliRunner()
94+
with patch("tango.client.httpx.Client.request", return_value=mock_response):
95+
result = runner.invoke(
96+
main,
97+
[
98+
"webhooks",
99+
"fetch-sample",
100+
"--event-type",
101+
"entities.updated",
102+
"--api-key",
103+
"k",
104+
],
105+
)
106+
assert result.exit_code == 0, result.output
107+
assert json.loads(result.output) == sample
108+
109+
110+
def test_cli_list_event_types_prints_table() -> None:
111+
from unittest.mock import Mock, patch
112+
113+
api_response = {
114+
"event_types": [
115+
{
116+
"event_type": "entities.updated",
117+
"default_subject_type": "entity",
118+
"description": "Entity updated",
119+
"schema_version": 1,
120+
},
121+
{
122+
"event_type": "awards.created",
123+
"default_subject_type": "award",
124+
"description": "New award",
125+
"schema_version": 1,
126+
},
127+
],
128+
"subject_types": [],
129+
"subject_type_definitions": [],
130+
}
131+
mock_response = Mock()
132+
mock_response.status_code = 200
133+
mock_response.headers = {}
134+
mock_response.json.return_value = api_response
135+
mock_response.raise_for_status = Mock()
136+
137+
runner = CliRunner()
138+
with patch("tango.client.httpx.Client.request", return_value=mock_response):
139+
result = runner.invoke(main, ["webhooks", "list-event-types", "--api-key", "k"])
140+
assert result.exit_code == 0, result.output
141+
assert "entities.updated" in result.output
142+
assert "Entity updated" in result.output
143+
assert "awards.created" in result.output
144+
145+
75146
def test_cli_simulate_rejects_both_modes() -> None:
76147
runner = CliRunner()
77148
result = runner.invoke(

0 commit comments

Comments
 (0)