Skip to content

Commit f9f1bbe

Browse files
committed
Enhancement: add host-all serve alias and document binding options
- Add pypnm serve --host-all as a convenience alias for 0.0.0.0 - Keep --host available for explicit interface and address binding - Resolve the effective bind host consistently in serve output and uvicorn launch - Add CLI coverage for host-all parsing and runtime binding behavior - Update README and user docs to explain all-interface and single-interface binding - 2026-04-18 18:52:48
1 parent f155081 commit f9f1bbe

7 files changed

Lines changed: 71 additions & 11 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,18 @@ HTTP (default: `http://127.0.0.1:8000`):
206206
pypnm serve
207207
```
208208

209+
Expose PyPNM on all IPv4 interfaces assigned to the host:
210+
211+
```bash
212+
pypnm serve --host-all
213+
```
214+
215+
Bind to one specific interface or address:
216+
217+
```bash
218+
pypnm serve --host 192.168.1.20
219+
```
220+
209221
Development hot-reload:
210222

211223
```bash

docs/system/pypnm-cli.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Starts the FastAPI service (`pypnm.api.main:app`) through Uvicorn.
2828
Common options:
2929

3030
- `--host` (default `127.0.0.1`)
31+
- `--host-all` (bind to `0.0.0.0`)
3132
- `--port` (default `8000`)
3233
- `--ssl`, `--cert`, `--key`
3334
- `--log-level {critical,error,warning,info,debug,trace}`
@@ -48,7 +49,7 @@ Examples:
4849

4950
```bash
5051
pypnm serve
51-
pypnm serve --host 0.0.0.0 --port 8080
52+
pypnm serve --host-all --port 8080
5253
pypnm serve --reload
5354
pypnm serve --workers 4 --limit-max-requests 2000
5455
pypnm serve --run-background
@@ -60,6 +61,8 @@ pypnm serve --mute-tags "Orchestrator,Operational" --mute-tags-hard
6061

6162
Notes:
6263

64+
- `--host-all` is a convenience alias for binding on all IPv4 interfaces assigned to the host.
65+
- Use `--host <ip-or-name>` when you want to bind to one specific interface or address.
6366
- When `--reload` is enabled, `--workers` is forced to `1`.
6467
- `--run-background` detaches the service, writes a pidfile, and redirects stdout/stderr to a log file.
6568
- `--run-background` cannot be used with `--reload`.
@@ -98,5 +101,5 @@ New:
98101

99102
```bash
100103
pypnm serve --reload
101-
pypnm serve --host 0.0.0.0 --port 8000
104+
pypnm serve --host-all --port 8000
102105
```

docs/system/worker-sizing.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,13 @@ Suggested memory guardrails:
9292

9393
```bash
9494
# Small node
95-
pypnm serve --host 0.0.0.0 --port 8000 --workers 1 --limit-max-requests 1000
95+
pypnm serve --host-all --port 8000 --workers 1 --limit-max-requests 1000
9696

9797
# Standard production node
98-
pypnm serve --host 0.0.0.0 --port 8000 --workers 4 --limit-max-requests 2000
98+
pypnm serve --host-all --port 8000 --workers 4 --limit-max-requests 2000
9999

100100
# Higher-capacity node with proven concurrency need
101-
pypnm serve --host 0.0.0.0 --port 8000 --workers 6 --limit-max-requests 2000
101+
pypnm serve --host-all --port 8000 --workers 6 --limit-max-requests 2000
102102
```
103103

104104
## Practical Guidance

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
66

77
[project]
88
name = "pypnm-docsis"
9-
version = "1.6.3.1"
9+
version = "1.6.3.2"
1010
description = "DOCSIS 3.x/4.0 Proactive Network Maintenance Toolkit"
1111
readme = "README.md"
1212
requires-python = ">=3.10"

src/pypnm/cli.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
DEFAULT_WORKERS = 1
4242
TIMEOUT_KEEP_ALIVE_SECONDS = 120
4343
DEFAULT_LIMIT_MAX_REQUESTS = 0
44+
ALL_INTERFACES_HOST = "0.0.0.0"
4445

4546

4647
def _runtime_profile_selection_message(workers: int, limit_max_requests: int) -> str:
@@ -115,6 +116,10 @@ def _sanitize_pythonpath_for_serve() -> None:
115116
os.environ["PYTHONPATH"] = src_path
116117

117118

119+
def _resolve_bind_host(args: argparse.Namespace) -> str:
120+
return ALL_INTERFACES_HOST if bool(getattr(args, "host_all", False)) else str(args.host)
121+
122+
118123
def _build_parser() -> argparse.ArgumentParser:
119124
parser = argparse.ArgumentParser(
120125
description="PyPNM CLI for service startup and system configuration.",
@@ -140,7 +145,13 @@ def _build_parser() -> argparse.ArgumentParser:
140145
subparsers = parser.add_subparsers(dest="command")
141146

142147
serve_parser = subparsers.add_parser("serve", help="Start the FastAPI service (Uvicorn).")
143-
serve_parser.add_argument("--host", default=HOST_DEFAULT, help=f"Host to bind (default: {HOST_DEFAULT})")
148+
host_group = serve_parser.add_mutually_exclusive_group()
149+
host_group.add_argument("--host", default=HOST_DEFAULT, help=f"Host to bind (default: {HOST_DEFAULT})")
150+
host_group.add_argument(
151+
"--host-all",
152+
action="store_true",
153+
help=f"Bind on all IPv4 interfaces ({ALL_INTERFACES_HOST}).",
154+
)
144155
serve_parser.add_argument("--port", default=PORT_DEFAULT, type=int, help=f"Port to bind (default: {PORT_DEFAULT})")
145156
serve_parser.add_argument("--ssl", action="store_true", help="Enable HTTPS (requires cert and key).")
146157
serve_parser.add_argument("--cert", default="./certs/cert.pem", help="Path to SSL certificate (PEM).")
@@ -230,6 +241,7 @@ def _build_parser() -> argparse.ArgumentParser:
230241

231242

232243
def _run_serve(args: argparse.Namespace) -> int:
244+
bind_host = _resolve_bind_host(args)
233245
run_background = bool(getattr(args, "run_background", False))
234246
background_log_file = str(getattr(args, "background_log_file", "")).strip()
235247
background_pidfile = str(getattr(args, "background_pidfile", "")).strip()
@@ -239,9 +251,9 @@ def _run_serve(args: argparse.Namespace) -> int:
239251
return EXIT_CODE_USAGE
240252

241253
if args.ssl:
242-
print(f"🔒 Launching FastAPI with HTTPS on https://{args.host}:{args.port}")
254+
print(f"🔒 Launching FastAPI with HTTPS on https://{bind_host}:{args.port}")
243255
else:
244-
print(f"🌐 Launching FastAPI with HTTP on http://{args.host}:{args.port}")
256+
print(f"🌐 Launching FastAPI with HTTP on http://{bind_host}:{args.port}")
245257

246258
_sanitize_pythonpath_for_serve()
247259

@@ -269,7 +281,7 @@ def _run_serve(args: argparse.Namespace) -> int:
269281

270282
uvicorn_args = {
271283
"app": "pypnm.api.main:app",
272-
"host": args.host,
284+
"host": bind_host,
273285
"port": args.port,
274286
"timeout_keep_alive": TIMEOUT_KEEP_ALIVE_SECONDS,
275287
"log_level": args.log_level,

src/pypnm/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
__all__ = ["__version__"]
77

88
# MAJOR.MINOR.MAINTENANCE.BUILD
9-
__version__: str = "1.6.3.1"
9+
__version__: str = "1.6.3.2"

tests/test_cli.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
def _serve_args(**overrides: object) -> Namespace:
1616
base = {
1717
"host": "127.0.0.1",
18+
"host_all": False,
1819
"port": 8000,
1920
"ssl": False,
2021
"cert": "./certs/cert.pem",
@@ -58,6 +59,38 @@ def fake_uvicorn_run(**kwargs: object) -> None:
5859
assert recorded["workers"] == 2
5960

6061

62+
def test_run_serve_host_all_binds_all_ipv4_interfaces(monkeypatch, capsys) -> None:
63+
recorded: dict[str, object] = {}
64+
65+
def fake_uvicorn_run(**kwargs: object) -> None:
66+
recorded.update(kwargs)
67+
68+
monkeypatch.setattr(cli, "_sanitize_pythonpath_for_serve", lambda: None)
69+
monkeypatch.setattr(cli.uvicorn, "run", fake_uvicorn_run)
70+
monkeypatch.setattr(
71+
cli,
72+
"detect_worker_profile",
73+
lambda: cli.WorkerProfile(cpu_count=4, total_memory_gib=16.0, workers=2, limit_max_requests=1000),
74+
)
75+
76+
exit_code = cli._run_serve(_serve_args(host_all=True))
77+
captured = capsys.readouterr()
78+
79+
assert exit_code == cli.SUCCESS_EXIT_CODE
80+
assert recorded["host"] == cli.ALL_INTERFACES_HOST
81+
assert f"http://{cli.ALL_INTERFACES_HOST}:8000" in captured.out
82+
83+
84+
def test_build_parser_host_all_sets_all_interfaces_host() -> None:
85+
parser = cli._build_parser()
86+
87+
args = parser.parse_args(["serve", "--host-all"])
88+
89+
assert args.command == "serve"
90+
assert args.host_all is True
91+
assert cli._resolve_bind_host(args) == cli.ALL_INTERFACES_HOST
92+
93+
6194
def test_run_serve_rejects_negative_limit_max_requests(monkeypatch) -> None:
6295
uvicorn_called = False
6396

0 commit comments

Comments
 (0)