Skip to content

Commit ceb7d2e

Browse files
Refine top-level CLI app help (#163)
## Summary - reorganize the top-level CLI help into actions, apps, and player options - add an explicit `player` app alias while keeping plain `sendspin` as the default behavior - hide deprecated `--headless` from help output while continuing to accept it ## Verification - `uv run ruff check sendspin/cli.py` - `python -m sendspin.cli --help` - `python -m sendspin.cli --version` - `python -m sendspin.cli --url ws://example.invalid/sendspin player --version` ## Notes - `pytest -q` did not discover any tests in this checkout, so verification for this change is manual plus linting. --------- Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
1 parent 4d98e44 commit ceb7d2e

1 file changed

Lines changed: 154 additions & 94 deletions

File tree

‎sendspin/cli.py‎

Lines changed: 154 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import traceback
1212
from collections.abc import Sequence
1313
from importlib.metadata import version
14-
from typing import TYPE_CHECKING
14+
from typing import TYPE_CHECKING, Any, Protocol
1515

1616
from sendspin.hardware_volume import AVAILABLE as HW_VOLUME_AVAILABLE
1717
from sendspin.hardware_volume import UNAVAILABLE_REASON as HW_VOLUME_UNAVAILABLE_REASON
@@ -32,6 +32,15 @@
3232
• macOS: brew install portaudio
3333
• Other systems: https://www.portaudio.com/"""
3434

35+
PLAYER_APP_SENTINEL = "player"
36+
37+
38+
class ArgumentTarget(Protocol):
39+
"""Minimal protocol for parser-like objects that accept arguments."""
40+
41+
def add_argument(self, *name_or_flags: str, **kwargs: Any) -> argparse.Action:
42+
"""Add an argument to the target."""
43+
3544

3645
def arg_str_to_bool(v: str) -> bool:
3746
s = v.lower()
@@ -72,15 +81,145 @@ def list_audio_devices() -> None:
7281
sys.exit(1)
7382

7483

75-
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
76-
"""Parse CLI arguments for the Sendspin client."""
77-
parser = argparse.ArgumentParser(description="Sendspin CLI")
84+
def _add_player_runtime_options(
85+
target: ArgumentTarget, *, suppress_defaults: bool = False
86+
) -> None:
87+
"""Add the interactive player's runtime options."""
88+
default: str | float | None
89+
default = argparse.SUPPRESS if suppress_defaults else None
90+
91+
target.add_argument(
92+
"--url",
93+
default=default,
94+
help=("WebSocket URL of the Sendspin server. If omitted, discover via mDNS."),
95+
)
96+
target.add_argument(
97+
"--name",
98+
default=default,
99+
help="Friendly name for this client (defaults to hostname)",
100+
)
101+
target.add_argument(
102+
"--id",
103+
default=default,
104+
help="Unique identifier for this client (defaults to sendspin-cli-<hostname>)",
105+
)
106+
target.add_argument(
107+
"--log-level",
108+
default=default,
109+
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
110+
help="Logging level to use (default: INFO)",
111+
)
112+
target.add_argument(
113+
"--static-delay-ms",
114+
type=float,
115+
default=default,
116+
help="Extra playback delay in milliseconds applied after clock sync",
117+
)
118+
target.add_argument(
119+
"--audio-device",
120+
type=str,
121+
default=default,
122+
help=(
123+
"Audio output device by index (e.g., 0, 1, 2) or name prefix (e.g., 'MacBook'). "
124+
"Use --list-audio-devices to see available devices."
125+
),
126+
)
127+
target.add_argument(
128+
"--audio-format",
129+
type=str,
130+
default=default,
131+
help=(
132+
"Preferred audio format as codec:sample_rate:bit_depth:channels "
133+
"(e.g., flac:48000:24:2). Verified against the audio device on startup."
134+
),
135+
)
136+
target.add_argument(
137+
"--disable-mpris",
138+
action="store_true",
139+
default=argparse.SUPPRESS if suppress_defaults else False,
140+
help="Disable MPRIS integration",
141+
)
142+
target.add_argument(
143+
"--hardware-volume",
144+
default=default,
145+
type=arg_str_to_bool,
146+
metavar="{true,false}",
147+
help="Enable or disable hardware/system volume control (daemon: on, TUI: off)",
148+
)
149+
target.add_argument(
150+
"--hook-start",
151+
type=str,
152+
default=default,
153+
help="Command to run when audio stream starts (receives SENDSPIN_* env vars)",
154+
)
155+
target.add_argument(
156+
"--hook-stop",
157+
type=str,
158+
default=default,
159+
help="Command to run when audio stream stops (receives SENDSPIN_* env vars)",
160+
)
161+
162+
163+
def _add_player_actions(target: ArgumentTarget, *, suppress_defaults: bool = False) -> None:
164+
"""Add actions that should also work with the player app."""
165+
target.add_argument(
166+
"--list-audio-devices",
167+
action="store_true",
168+
default=argparse.SUPPRESS if suppress_defaults else False,
169+
help="List available audio output devices and exit",
170+
)
171+
target.add_argument(
172+
"--list-servers",
173+
action="store_true",
174+
default=argparse.SUPPRESS if suppress_defaults else False,
175+
help="Discover and list available Sendspin servers on the network",
176+
)
177+
target.add_argument(
178+
"--list-clients",
179+
action="store_true",
180+
default=argparse.SUPPRESS if suppress_defaults else False,
181+
help="Discover and list available Sendspin clients on the network",
182+
)
183+
target.add_argument(
184+
"--headless",
185+
action="store_true",
186+
default=argparse.SUPPRESS if suppress_defaults else False,
187+
help=argparse.SUPPRESS,
188+
)
189+
190+
191+
def _build_parser() -> argparse.ArgumentParser:
192+
"""Build the top-level CLI parser."""
193+
parser = argparse.ArgumentParser(
194+
prog="sendspin",
195+
description="Sendspin CLI",
196+
)
197+
198+
# Keep top-level actions separate from the TUI player's runtime options.
199+
parser._optionals.title = "Actions"
78200
parser.add_argument(
79201
"--version",
80202
action="version",
81203
version=f"%(prog)s {version('sendspin')}",
82204
)
83-
subparsers = parser.add_subparsers(dest="command", help="Available commands")
205+
subparsers = parser.add_subparsers(
206+
dest="command",
207+
title="Apps",
208+
help="Available apps (default: player)",
209+
)
210+
211+
player_parser = subparsers.add_parser(
212+
PLAYER_APP_SENTINEL,
213+
description="Run the interactive player app.",
214+
help="Run the interactive player app (default)",
215+
)
216+
player_parser.add_argument(
217+
"--version",
218+
action="version",
219+
version=f"%(prog)s {version('sendspin')}",
220+
)
221+
_add_player_runtime_options(player_parser, suppress_defaults=True)
222+
_add_player_actions(player_parser, suppress_defaults=True)
84223

85224
# Serve subcommand
86225
serve_parser = subparsers.add_parser("serve", help="Start a Sendspin server")
@@ -205,6 +344,7 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
205344
"--hardware-volume",
206345
default=None,
207346
type=arg_str_to_bool,
347+
metavar="{true,false}",
208348
help="Enable or disable hardware/system volume control (daemon: on, TUI: off)",
209349
)
210350
daemon_parser.add_argument(
@@ -221,95 +361,15 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
221361
)
222362

223363
# Default behavior (client mode) - existing arguments
224-
parser.add_argument(
225-
"--url",
226-
default=None,
227-
help=("WebSocket URL of the Sendspin server. If omitted, discover via mDNS."),
228-
)
229-
parser.add_argument(
230-
"--name",
231-
default=None,
232-
help="Friendly name for this client (defaults to hostname)",
233-
)
234-
parser.add_argument(
235-
"--id",
236-
default=None,
237-
help="Unique identifier for this client (defaults to sendspin-cli-<hostname>)",
238-
)
239-
parser.add_argument(
240-
"--log-level",
241-
default=None,
242-
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
243-
help="Logging level to use (default: INFO)",
244-
)
245-
parser.add_argument(
246-
"--static-delay-ms",
247-
type=float,
248-
default=None,
249-
help="Extra playback delay in milliseconds applied after clock sync",
250-
)
251-
parser.add_argument(
252-
"--audio-device",
253-
type=str,
254-
default=None,
255-
help=(
256-
"Audio output device by index (e.g., 0, 1, 2) or name prefix (e.g., 'MacBook'). "
257-
"Use --list-audio-devices to see available devices."
258-
),
259-
)
260-
parser.add_argument(
261-
"--audio-format",
262-
type=str,
263-
default=None,
264-
help=(
265-
"Preferred audio format as codec:sample_rate:bit_depth:channels "
266-
"(e.g., flac:48000:24:2). Verified against the audio device on startup."
267-
),
268-
)
269-
parser.add_argument(
270-
"--list-audio-devices",
271-
action="store_true",
272-
help="List available audio output devices and exit",
273-
)
274-
parser.add_argument(
275-
"--list-servers",
276-
action="store_true",
277-
help="Discover and list available Sendspin servers on the network",
278-
)
279-
parser.add_argument(
280-
"--list-clients",
281-
action="store_true",
282-
help="Discover and list available Sendspin clients on the network",
283-
)
284-
parser.add_argument(
285-
"--disable-mpris",
286-
action="store_true",
287-
help="Disable MPRIS integration",
288-
)
289-
parser.add_argument(
290-
"--hardware-volume",
291-
default=None,
292-
type=arg_str_to_bool,
293-
help="Enable or disable hardware/system volume control (daemon: on, TUI: off)",
294-
)
295-
parser.add_argument(
296-
"--headless",
297-
action="store_true",
298-
help="(DEPRECATED: use 'sendspin daemon' instead) Run without the interactive terminal UI",
299-
)
300-
parser.add_argument(
301-
"--hook-start",
302-
type=str,
303-
default=None,
304-
help="Command to run when audio stream starts (receives SENDSPIN_* env vars)",
305-
)
306-
parser.add_argument(
307-
"--hook-stop",
308-
type=str,
309-
default=None,
310-
help="Command to run when audio stream stops (receives SENDSPIN_* env vars)",
311-
)
312-
return parser.parse_args(argv)
364+
tui_player_options = parser.add_argument_group("Player options")
365+
_add_player_runtime_options(tui_player_options)
366+
_add_player_actions(parser)
367+
return parser
368+
369+
370+
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
371+
"""Parse CLI arguments for the Sendspin client."""
372+
return _build_parser().parse_args(argv)
313373

314374

315375
async def list_servers() -> None:

0 commit comments

Comments
 (0)