diff --git a/tempest/references/cli-worked-recipes.md b/tempest/references/cli-worked-recipes.md index 347f2b0..77908d2 100644 --- a/tempest/references/cli-worked-recipes.md +++ b/tempest/references/cli-worked-recipes.md @@ -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 diff --git a/tempest/scripts/tempest b/tempest/scripts/tempest index 0592f09..6061107 100755 --- a/tempest/scripts/tempest +++ b/tempest/scripts/tempest @@ -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)) diff --git a/tempest/scripts/test_tempest.py b/tempest/scripts/test_tempest.py index edfe085..ab778ac 100644 --- a/tempest/scripts/test_tempest.py +++ b/tempest/scripts/test_tempest.py @@ -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)