Skip to content

Commit ee99d2b

Browse files
Merge pull request #237 from ezmsg-org/chore/cmdline-tlc
QOL: Command Line TLC
2 parents b3663b2 + b70dbde commit ee99d2b

11 files changed

Lines changed: 363 additions & 194 deletions

File tree

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ axisarray = [
5555

5656
[project.scripts]
5757
ezmsg = "ezmsg.core.command:cmdline"
58-
ezmsg-perf = "ezmsg.util.perf.command:command"
5958

6059
[project.optional-dependencies]
6160
axisarray = [

src/ezmsg/core/command.py

Lines changed: 46 additions & 187 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,30 @@
11
import argparse
22
import asyncio
3-
import base64
4-
import json
5-
import logging
6-
import subprocess
7-
import sys
8-
import webbrowser
9-
import zlib
3+
import inspect
104

11-
from .graphserver import GraphService
5+
from ezmsg.util.perf.command import setup_perf_cmdline
6+
7+
from .commands import setup_core_cmdline
8+
from .commands.graphviz import handle_graphviz
9+
from .commands.mermaid import handle_mermaid, mermaid_url as mm
10+
from .commands.serve import handle_serve
11+
from .commands.shutdown import handle_shutdown
12+
from .commands.start import handle_start
1213
from .netprotocol import (
1314
Address,
1415
GRAPHSERVER_ADDR_ENV,
1516
GRAPHSERVER_PORT_DEFAULT,
1617
PUBLISHER_START_PORT_ENV,
1718
PUBLISHER_START_PORT_DEFAULT,
18-
close_stream_writer,
1919
)
2020

21-
logger = logging.getLogger("ezmsg")
22-
2321

24-
def cmdline() -> None:
22+
def build_parser() -> argparse.ArgumentParser:
2523
"""
26-
Command-line interface for ezmsg core server management.
24+
Build the ezmsg core command-line parser.
2725
28-
Provides commands for starting, stopping, and managing ezmsg server
29-
processes including GraphServer and SHMServer, as well as utilities
30-
for graph visualization.
26+
Each command gets its own subparser so command-specific options are not
27+
shared globally across unrelated commands.
3128
"""
3229
parser = argparse.ArgumentParser(
3330
"ezmsg.core",
@@ -38,63 +35,27 @@ def cmdline() -> None:
3835
Publishers will be assigned available ports starting from {PUBLISHER_START_PORT_DEFAULT}. (Change with ${PUBLISHER_START_PORT_ENV})
3936
""",
4037
)
38+
subparsers = parser.add_subparsers(dest="command", required=True, help="command for ezmsg")
4139

42-
parser.add_argument(
43-
"command",
44-
help="command for ezmsg",
45-
choices=["serve", "start", "shutdown", "graphviz", "mermaid"],
46-
)
47-
48-
parser.add_argument("--address", help="Address for GraphServer", default=None)
49-
50-
parser.add_argument(
51-
"--target",
52-
help="Target for mermaid output. Options are 'ink', 'live', and 'play'.",
53-
default="live",
54-
)
55-
56-
parser.add_argument(
57-
"-c",
58-
"--compact",
59-
help="""Use compact graph representation. Only used when `cmd` is 'mermaid' or 'graphviz'.
60-
Removes the lowest level of detail (typically streams). Can be stacked (eg. '-cc').
61-
Warning: this will also prune the graph of proxy topics (nodes that are both sources and targets).
62-
""",
63-
action="count",
64-
)
65-
66-
parser.add_argument(
67-
"-n",
68-
"--nobrowser",
69-
help="Do not automatically open the browser for mermaid output. `--target` value will be ignored.",
70-
action="store_true",
71-
)
72-
73-
class Args:
74-
command: str
75-
address: str | None
76-
target: str
77-
compact: int | None
78-
nobrowser: bool
40+
setup_core_cmdline(subparsers)
41+
setup_perf_cmdline(subparsers)
42+
return parser
7943

80-
args = parser.parse_args(namespace=Args)
8144

82-
graph_address = Address("127.0.0.1", GRAPHSERVER_PORT_DEFAULT)
83-
if args.address is not None:
84-
graph_address = Address.from_string(args.address)
45+
def cmdline(argv: list[str] | None = None) -> None:
46+
"""
47+
Command-line interface for ezmsg core server management.
8548
86-
loop = asyncio.new_event_loop()
87-
asyncio.set_event_loop(loop)
49+
Provides commands for starting, stopping, and managing ezmsg server
50+
processes including GraphServer and SHMServer, as well as utilities
51+
for graph visualization.
52+
"""
53+
parser = build_parser()
54+
args = parser.parse_args(args=argv)
8855

89-
loop.run_until_complete(
90-
run_command(
91-
args.command,
92-
graph_address,
93-
args.target,
94-
args.compact,
95-
args.nobrowser,
96-
)
97-
)
56+
result = args._handler(args)
57+
if inspect.isawaitable(result):
58+
asyncio.run(result)
9859

9960

10061
async def run_command(
@@ -104,122 +65,20 @@ async def run_command(
10465
compact: int | None = None,
10566
nobrowser: bool = False,
10667
) -> None:
107-
"""
108-
Run an ezmsg command with the specified parameters.
109-
110-
This function handles various ezmsg commands like 'serve', 'start', 'shutdown', etc.
111-
and manages the graph and shared memory services.
112-
113-
:param cmd: The command to execute ('serve', 'start', 'shutdown', 'graphviz', 'mermaid')
114-
:type cmd: str
115-
:param graph_address: Address of the graph service
116-
:type graph_address: Address
117-
:param target: Target for visualization commands (default: 'live')
118-
:type target: str
119-
:param compact: Compactification level for visualization commands
120-
:type compact: int | None
121-
:param nobrowser: Whether to suppress browser opening for visualization
122-
:type nobrowser: bool
123-
"""
124-
graph_service = GraphService(graph_address)
125-
126-
if cmd == "serve":
127-
logger.info(f"GraphServer Address: {graph_address}")
128-
129-
graph_server = graph_service.create_server()
130-
131-
try:
132-
logger.info("Servers running...")
133-
graph_server.join()
134-
135-
except KeyboardInterrupt:
136-
logger.info("Interrupt detected; shutting down servers")
137-
138-
finally:
139-
if graph_server is not None:
140-
graph_server.stop()
141-
142-
elif cmd == "start":
143-
popen = subprocess.Popen(
144-
[sys.executable, "-m", "ezmsg.core", "serve", f"--address={graph_address}"]
145-
)
146-
147-
while True:
148-
try:
149-
_, writer = await graph_service.open_connection()
150-
await close_stream_writer(writer)
151-
break
152-
except ConnectionRefusedError:
153-
await asyncio.sleep(0.1)
154-
155-
logger.info(f"Forked ezmsg servers in PID: {popen.pid}")
156-
157-
elif cmd == "shutdown":
158-
try:
159-
await graph_service.shutdown()
160-
logger.info(
161-
f"Issued shutdown command to GraphServer @ {graph_service.address}"
162-
)
163-
164-
except ConnectionRefusedError:
165-
logger.warning(
166-
f"Could not issue shutdown command to GraphServer @ {graph_service.address}; server not running?"
167-
)
168-
169-
elif cmd in ["graphviz", "mermaid"]:
170-
graph_out = await graph_service.get_formatted_graph(
171-
fmt=cmd, compact_level=compact
172-
)
173-
print(graph_out)
174-
if cmd == "mermaid":
175-
if not nobrowser:
176-
if target == "live":
177-
print(
178-
"%% If the graph does not render immediately, try toggling the 'Pan & Zoom' button."
179-
)
180-
webbrowser.open(mm(graph_out, target=target))
181-
182-
183-
def mm(graph: str, target="live") -> str:
184-
"""
185-
Generate a Mermaid visualization URL for the given graph.
186-
187-
:param graph: Graph representation string to visualize.
188-
:type graph: str
189-
:param target: Target platform ('live' or 'ink').
190-
:type target: str
191-
:return: URL for graph visualization.
192-
:rtype: str
193-
"""
194-
if target != "ink":
195-
jdict = {
196-
"code": graph,
197-
"mermaid": {"theme": "default"},
198-
"updateDiagram": True,
199-
"autoSync": True,
200-
"rough": False,
201-
}
202-
graph = json.dumps(jdict)
203-
graphbytes: bytes = graph.encode("utf8")
204-
205-
if target != "ink":
206-
compress = zlib.compressobj(9, zlib.DEFLATED, 15, 8, zlib.Z_DEFAULT_STRATEGY)
207-
graphbytes = compress.compress(graphbytes)
208-
graphbytes += compress.flush()
209-
210-
base64_bytes = base64.b64encode(graphbytes)
211-
base64_string = base64_bytes.decode("ascii")
212-
213-
if target == "ink":
214-
prefix = "https://mermaid.ink/img/"
215-
elif target in ["live", "play"]:
216-
type_str = "pako" # or "base64" if we skip compression above.
217-
if target == "live":
218-
prefix = f"https://mermaid.live/edit#{type_str}:"
219-
else: # "play"
220-
prefix = f"https://www.mermaidchart.com/play#{type_str}:"
221-
else:
222-
raise ValueError(
223-
f"Unknown mermaid target '{target}'. Available options are 'ink', 'live', or 'play'."
224-
)
225-
return prefix + base64_string
68+
handlers = {
69+
"serve": handle_serve,
70+
"start": handle_start,
71+
"shutdown": handle_shutdown,
72+
"graphviz": handle_graphviz,
73+
"mermaid": handle_mermaid,
74+
}
75+
if cmd not in handlers:
76+
raise ValueError(f"Unknown ezmsg command '{cmd}'")
77+
args = argparse.Namespace(
78+
command=cmd,
79+
address=str(graph_address),
80+
target=target,
81+
compact=compact,
82+
nobrowser=nobrowser,
83+
)
84+
await handlers[cmd](args)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import argparse
2+
3+
from .graphviz import setup_graphviz_cmdline
4+
from .mermaid import setup_mermaid_cmdline
5+
from .serve import setup_serve_cmdline
6+
from .shutdown import setup_shutdown_cmdline
7+
from .start import setup_start_cmdline
8+
9+
10+
def setup_core_cmdline(subparsers: argparse._SubParsersAction) -> None:
11+
setup_serve_cmdline(subparsers)
12+
setup_start_cmdline(subparsers)
13+
setup_shutdown_cmdline(subparsers)
14+
setup_graphviz_cmdline(subparsers)
15+
setup_mermaid_cmdline(subparsers)

src/ezmsg/core/commands/common.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import argparse
2+
3+
from ..netprotocol import Address, GRAPHSERVER_PORT_DEFAULT
4+
5+
6+
def add_address_argument(parser: argparse.ArgumentParser) -> None:
7+
parser.add_argument("--address", help="Address for GraphServer", default=None)
8+
9+
10+
def add_compact_argument(parser: argparse.ArgumentParser) -> None:
11+
parser.add_argument(
12+
"-c",
13+
"--compact",
14+
help="""Use compact graph representation.
15+
Removes the lowest level of detail (typically streams). Can be stacked (eg. '-cc').
16+
Warning: this will also prune the graph of proxy topics (nodes that are both sources and targets).
17+
""",
18+
action="count",
19+
)
20+
21+
22+
def graph_address_from_args(args: argparse.Namespace) -> Address:
23+
if args.address is None:
24+
return Address("127.0.0.1", GRAPHSERVER_PORT_DEFAULT)
25+
return Address.from_string(args.address)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import argparse
2+
3+
from ..graphserver import GraphService
4+
from .common import add_address_argument, add_compact_argument, graph_address_from_args
5+
6+
7+
async def handle_graphviz(args: argparse.Namespace) -> None:
8+
graph_service = GraphService(graph_address_from_args(args))
9+
graph_out = await graph_service.get_formatted_graph(
10+
fmt="graphviz", compact_level=args.compact
11+
)
12+
print(graph_out)
13+
14+
15+
def setup_graphviz_cmdline(subparsers: argparse._SubParsersAction) -> None:
16+
parser = subparsers.add_parser("graphviz")
17+
add_address_argument(parser)
18+
add_compact_argument(parser)
19+
parser.set_defaults(_handler=handle_graphviz)

0 commit comments

Comments
 (0)