fix(tempest): honor --dry-run on udp listen without binding a socket

udp listen ignored the universal --dry-run flag and bound UDP 50222,
hanging when no hub is on the LAN. Add a dry-run plan branch that
describes the listen parameters (bind address, port, timeout, show-all)
and exits 0 without creating any socket, so doc claims of universal
--dry-run support stay universal and true.

Four regression tests pin the plan shape, the defaults/--show-all
propagation, and prove no socket is constructed (and no token demanded).
Recipe 5 documents the udp listen plan object alongside the other plans.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
Magnus Hedemark
2026-08-30 01:52:10 -04:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 2a9a81e29b
commit ce5f34a33d
3 changed files with 72 additions and 3 deletions
+11 -3
View File
@@ -121,16 +121,24 @@ on `type` before indexing.
tempest forecast --station-id 12799 --days 3 --dry-run --json
# -> {"dry_run": true, "command": "forecast", "station_id": 12799, "days": 3}
# Every documented command has a dry-run plan: current, obs, forecast, stations
# Every documented command has a dry-run plan current, obs, forecast,
# stations, and udp listen (plans the bind, creates no socket, safe off-LAN)
tempest obs --device-id 60526 --days 2 --dry-run --json
tempest udp listen --port 50222 --timeout 30 --dry-run --json
# -> {"dry_run": true, "command": "udp", "subcommand": "listen",
# "bind_address": "0.0.0.0", "port": 50222, "timeout_seconds": 30,
# "show_all": false}
# Quiet/verbose piping: logs on stderr, data on stdout
tempest current --json --quiet | jq .observation.air_temperature
```
Behavior contract: `--dry-run` works without `TEMPEST_TOKEN` set (no credential
needed to see a plan); `--help` and `--dry-run` are always offline. Without
`--dry-run`, a missing token exits 1 with
needed to see a plan); `--help` and `--dry-run` are always offline. For
`udp listen`, dry-run describes the listen parameters (bind address, port,
timeout, show-all) and exits 0 without creating or binding any socket — the
real listener waits for hub traffic on UDP 50222 and needs the hub's LAN.
Without `--dry-run`, a missing token exits 1 with
`Error: TEMPEST_TOKEN not set...` before any request is attempted.
## Recipe 6: JSON error paths you'll actually see
+11
View File
@@ -732,6 +732,17 @@ def udp_listen(args: argparse.Namespace) -> None:
timeout = args.timeout
show_all = args.show_all
if GLOBAL_FLAGS.get("dry_run", False):
# Dry-run plans the listen instead of opening it: exits 0 without
# creating or binding any socket, so it is safe anywhere (no hub
# required, no LAN needed, no hanging on a broadcast port).
emit("[dry-run] Would listen for Tempest UDP broadcasts on "
f"{UDP_BROADCAST_ADDR}:{port} — binds no socket now.",
{"dry_run": True, "command": "udp", "subcommand": "listen",
"bind_address": UDP_BROADCAST_ADDR, "port": port,
"timeout_seconds": timeout, "show_all": show_all})
return
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((UDP_BROADCAST_ADDR, port))
+50
View File
@@ -316,6 +316,56 @@ class DryRunTests(CliTestCase):
self.assertEqual(code, 0)
self.assertEqual(json.loads(out)["command"], "stations")
def test_udp_listen_dry_run_plan_shape(self):
# VAL-TEMP-011: udp listen honors --dry-run — a plan JSON, exit 0,
# and (pinned by the socket patch below) NO socket is ever created
# or bound, so the dry run cannot hang waiting for hub traffic.
code, out = run_main(["udp", "listen", "--port", "50222",
"--timeout", "30", "--dry-run", "--json"])
self.assertEqual(code, 0)
plan = json.loads(out)
self.assertEqual(plan["dry_run"], True)
self.assertEqual(plan["command"], "udp")
self.assertEqual(plan["subcommand"], "listen")
self.assertEqual(plan["bind_address"], ts.UDP_BROADCAST_ADDR)
self.assertEqual(plan["port"], 50222)
self.assertEqual(plan["timeout_seconds"], 30)
self.assertEqual(plan["show_all"], False)
def test_udp_listen_dry_run_defaults_and_show_all(self):
# Defaults land in the plan; --show-all propagates.
code, out = run_main(["udp", "listen", "--show-all", "--dry-run", "--json"])
self.assertEqual(code, 0)
plan = json.loads(out)
self.assertEqual(plan["port"], ts.DEFAULT_UDP_PORT)
self.assertEqual(plan["timeout_seconds"], 0)
self.assertEqual(plan["show_all"], True)
def test_udp_listen_dry_run_creates_no_socket(self):
# Prove the "binds no socket" half of the contract: if udp_listen
# reached its listen path, socket.socket() would be constructed and
# this fake's bind() would blow up the test.
bound = []
class NoBindSock:
def bind(self, *a, **k):
bound.append(a)
raise AssertionError("dry-run udp listen must not bind a socket")
with patch.object(ts.socket, "socket", side_effect=AssertionError(
"dry-run udp listen must not create a socket")):
code, out = run_main(["udp", "listen", "--dry-run", "--json"])
self.assertEqual(code, 0)
self.assertEqual(bound, [])
self.assertEqual(json.loads(out)["dry_run"], True)
def test_udp_listen_dry_run_without_token_is_fine(self):
# UDP needs no token, and the dry run must not demand one either.
with patch_token(""):
code, out = run_main(["udp", "listen", "--dry-run", "--json"])
self.assertEqual(code, 0)
self.assertEqual(json.loads(out)["command"], "udp")
# ---------------------------------------------------------------------------
# Class 4: mocked REST client logic (no real network anywhere)